use crate::{ config::Config, detect, diagnose, launcher, proton, service, theme::{ btn_accent, btn_danger, btn_ghost, card_style, icon, section_heading, sub_card_style, surface_bg, ACCENT, BORDER_CLR, DIM, GREEN, MUTED, NO_SHADOW, RED, }, util::{async_blocking, pick_file, pick_folder}, }; use anyhow::Result; use iced::widget::{ button, column, container, mouse_area, pick_list, row, scrollable, text, text_input, Space, Column, }; use iced::{ Alignment, Background, Border, Color, Element, Length, Padding, Subscription, Task, Theme, }; use std::collections::HashMap; use std::path::PathBuf; use std::sync::{Arc, Mutex}; use std::time::Duration; /// What the user chose when closing the GUI window. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum CloseAction { Quit, MinimizeToTray, } #[derive(Debug, Clone)] pub enum Message { FontLoaded, PollProcesses, PollDone(HashMap), ReloadConfig, AddLauncher, Launch(String), LaunchDone(String, Result<(), String>), Kill(String), Play(String, String), PlayDone(String, String, Result<(), String>), Setup(String), ToggleGameMode(String, String), ToggleMangoHud(String, String), ToggleGamescope(String, String), UpdateProton, ProtonDone(Result<(), String>), KillDone(String, Result<(), String>), // Context menu ShowContextMenu(String), HideContextMenu, OpenPrefix(String), RerunSetup(String), RemoveLauncher(String), // Detect DetectPressed, DetectDone(Vec), // Diagnose DiagnosePressed(String), DiagnoseDone(String, Vec), HideDiagnose, // Games AddGamePressed(String), AddGameNameChanged(String, String), AddGameExeChanged(String, String), AddGameConfirm(String), RemoveGame(String, String), ScanGames(String), ScanGamesDone(String, Vec<(String, String)>), AddScannedGame(String, String, String), BrowseGameExe(String), BrowseGameExeDone(String, Option), // Settings ShowSettings, HideSettings, SettingsProtonVersionChanged(String), SettingsCompatDirChanged(String), BrowseCompatDir, BrowseCompatDirDone(Option), SaveSettings, ServiceInstall, ServiceUninstall, ServiceActionDone(Result<(), String>), LaunchProtontricks, // Close dialog CloseRequested(iced::window::Id), ConfirmQuit, ConfirmMinimize, CancelClose, } struct Dashboard { config: Config, running: HashMap, proton_busy: bool, proton_status: String, last_error: Option, context_menu: Option, /// Launchers currently being launched (show spinner instead of button). launch_busy: std::collections::HashSet, // Detect detect_busy: bool, detect_result: String, // Diagnose diagnose_open: Option, diagnose_result: Option<(String, Vec)>, // Games adding_game: HashMap, scan_results: HashMap>, scan_busy: std::collections::HashSet, // Settings settings_open: bool, settings_proton_version: String, settings_compat_dir: String, proton_versions: Vec, service_busy: bool, service_status: String, // Close dialog close_dialog_open: bool, close_action: Arc>>, } impl Dashboard { fn new(config: Config, close_action: Arc>>) -> Self { let mut running = HashMap::new(); for l in &config.launchers { running.insert(l.name.clone(), launcher::is_running(l)); } let settings_proton_version = config.proton_version.clone(); let settings_compat_dir = config.proton_compat_dir.to_string_lossy().into_owned(); let proton_versions = proton::list_installed(&config); Self { config, running, proton_busy: false, proton_status: String::new(), last_error: None, context_menu: None, launch_busy: std::collections::HashSet::new(), detect_busy: false, detect_result: String::new(), diagnose_open: None, diagnose_result: None, adding_game: HashMap::new(), scan_results: HashMap::new(), scan_busy: std::collections::HashSet::new(), settings_open: false, settings_proton_version, settings_compat_dir, proton_versions, service_busy: false, service_status: String::new(), close_dialog_open: false, close_action, } } } fn update(state: &mut Dashboard, msg: Message) -> Task { match msg { Message::FontLoaded => Task::none(), Message::PollProcesses => { let launchers: Vec<_> = state.config.launchers.iter() .map(|l| (l.name.clone(), l.process_pattern.clone())) .collect(); Task::perform( async_blocking(move || { let mut map = HashMap::new(); for (name, pattern) in launchers { let running = std::process::Command::new("pgrep") .args(["-f", &pattern]) .stdout(std::process::Stdio::null()) .stderr(std::process::Stdio::null()) .status() .is_ok_and(|s| s.success()); map.insert(name, running); } map }), Message::PollDone, ) } Message::PollDone(snapshot) => { state.running = snapshot; // Clear launch_busy for launchers that are now running state.launch_busy.retain(|n| !state.running.get(n).copied().unwrap_or_default()); Task::none() } Message::ReloadConfig => { if let Ok(fresh) = Config::load() { state .running .retain(|k, _| fresh.launchers.iter().any(|l| &l.name == k)); state.proton_versions = proton::list_installed(&fresh); state.config = fresh; } Task::none() } Message::AddLauncher => { state.context_menu = None; let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray")); let _ = std::process::Command::new(exe).arg("setup").spawn(); Task::none() } Message::Launch(name) => { state.last_error = None; state.launch_busy.insert(name.clone()); let Some(l) = state.config.find(&name) else { return Task::none(); }; let config = state.config.clone(); let l = l.clone(); let name2 = name.clone(); Task::perform( async_blocking(move || launcher::launch(&config, &l).map_err(|e| e.to_string())), move |res| Message::LaunchDone(name2.clone(), res), ) } Message::LaunchDone(name, res) => { state.launch_busy.remove(&name); match res { Ok(()) => { state.running.insert(name, true); } Err(e) => { state.last_error = Some(format!("Launch failed: {e}")); } } Task::none() } Message::Kill(name) => { state.last_error = None; let Some(l) = state.config.find(&name) else { return Task::none(); }; let l = l.clone(); let name2 = name.clone(); state.running.insert(name, false); Task::perform( async_blocking(move || { launcher::kill(&l); Ok::<(), String>(()) }), move |res| Message::KillDone(name2.clone(), res), ) } Message::KillDone(name, res) => { if let Err(e) = res { state.running.insert(name, true); state.last_error = Some(format!("Kill failed: {e}")); } Task::none() } Message::Play(lname, gname) => { state.last_error = None; if let Some(l) = state.config.find(&lname) { if let Some(g) = l.find_game(&gname) { let config = state.config.clone(); let l = l.clone(); let g = g.clone(); let lname2 = lname.clone(); let gname2 = gname.clone(); return Task::perform( async_blocking(move || { launcher::play_game(&config, &l, &g).map_err(|e| e.to_string()) }), move |res| Message::PlayDone(lname2.clone(), gname2.clone(), res), ); } } Task::none() } Message::PlayDone(_lname, _gname, res) => { if let Err(e) = res { state.last_error = Some(format!("Play failed: {e}")); } Task::none() } Message::Setup(name) => { let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray")); let _ = std::process::Command::new(exe) .arg("setup") .arg(&name) .spawn(); Task::none() } Message::ToggleGameMode(lname, gname) => { toggle_flag(&mut state.config, &lname, &gname, |g| { g.gamemode = !g.gamemode }); Task::none() } Message::ToggleMangoHud(lname, gname) => { toggle_flag(&mut state.config, &lname, &gname, |g| { g.mangohud = !g.mangohud }); Task::none() } Message::ToggleGamescope(lname, gname) => { toggle_flag(&mut state.config, &lname, &gname, |g| { g.gamescope = if g.gamescope.is_some() { None } else { Some(vec![]) }; }); Task::none() } Message::UpdateProton => { state.proton_busy = true; state.proton_status = "Downloading latest GE-Proton…".into(); state.last_error = None; let config = state.config.clone(); Task::perform( async_blocking(move || { crate::proton::install_latest(&config).map_err(|e| e.to_string()) }), Message::ProtonDone, ) } Message::ProtonDone(res) => { state.proton_busy = false; match res { Ok(()) => state.proton_status = "GE-Proton updated successfully.".into(), Err(e) => { state.proton_status = String::new(); state.last_error = Some(format!("Proton update failed: {e}")); } } Task::none() } // ── Context menu ─────────────────────────────────────────────────── Message::ShowContextMenu(name) => { state.context_menu = if state.context_menu.as_deref() == Some(&name) { None } else { Some(name) }; Task::none() } Message::HideContextMenu => { state.context_menu = None; Task::none() } Message::OpenPrefix(name) => { state.context_menu = None; if let Some(l) = state.config.find(&name) { let path = l.prefix_dir.clone(); std::thread::spawn(move || { let _ = std::process::Command::new("xdg-open").arg(path).spawn(); }); } Task::none() } Message::RerunSetup(name) => { state.context_menu = None; let exe = std::env::current_exe().unwrap_or_else(|_| PathBuf::from("umutray")); let _ = std::process::Command::new(exe) .arg("setup") .arg(&name) .spawn(); Task::none() } Message::RemoveLauncher(name) => { state.context_menu = None; state.running.remove(&name); state.adding_game.remove(&name); if let Err(e) = state.config.remove_launcher(&name) { state.last_error = Some(format!("Remove failed: {e}")); } Task::none() } // ── Detect ───────────────────────────────────────────────────────── Message::DetectPressed => { state.detect_busy = true; state.detect_result = "Scanning…".into(); let config = state.config.clone(); Task::perform( async_blocking(move || detect::scan_for_gui(&config)), Message::DetectDone, ) } Message::DetectDone(hits) => { state.detect_busy = false; if hits.is_empty() { state.detect_result = "No known launchers found on disk.".into(); } else { let parts: Vec = hits .iter() .map(|h| { if h.configured { format!("{} ✓", h.display) } else { format!("{} @ {}", h.display, h.prefix.display()) } }) .collect(); state.detect_result = format!("Found: {}", parts.join(", ")); } Task::none() } // ── Diagnose ─────────────────────────────────────────────────────── Message::DiagnosePressed(name) => { state.context_menu = None; state.diagnose_open = Some(name.clone()); state.diagnose_result = None; let config = state.config.clone(); let lname = name.clone(); Task::perform( async_blocking(move || { diagnose::run_checks(&config, Some(&name)) .unwrap_or_else(|e| vec![diagnose::CheckResult { label: "error".into(), pass: false, detail: e.to_string(), }]) }), move |checks| Message::DiagnoseDone(lname.clone(), checks), ) } Message::DiagnoseDone(name, checks) => { if state.diagnose_open.as_deref() == Some(&name) { state.diagnose_result = Some((name, checks)); } Task::none() } Message::HideDiagnose => { state.diagnose_open = None; state.diagnose_result = None; Task::none() } // ── Games ────────────────────────────────────────────────────────── Message::AddGamePressed(lname) => { use std::collections::hash_map::Entry; match state.adding_game.entry(lname) { Entry::Occupied(e) => { e.remove(); } Entry::Vacant(e) => { e.insert((String::new(), String::new())); } } Task::none() } Message::AddGameNameChanged(lname, val) => { if let Some(entry) = state.adding_game.get_mut(&lname) { entry.0 = val; } Task::none() } Message::AddGameExeChanged(lname, val) => { if let Some(entry) = state.adding_game.get_mut(&lname) { entry.1 = val; } Task::none() } Message::AddGameConfirm(lname) => { let Some((name, exe)) = state.adding_game.get(&lname).cloned() else { return Task::none(); }; let name = name.trim().to_string(); let exe = exe.trim().to_string(); if name.is_empty() || exe.is_empty() { state.last_error = Some("Game name and exe path are required.".into()); return Task::none(); } state.last_error = None; match state.config.add_game(&lname, name, None, PathBuf::from(exe), false, false, None) { Ok(()) => { state.adding_game.remove(&lname); } Err(e) => { state.last_error = Some(format!("Add game failed: {e}")); } } Task::none() } Message::RemoveGame(lname, gname) => { state.last_error = None; if let Err(e) = state.config.remove_game(&lname, &gname) { state.last_error = Some(format!("Remove game failed: {e}")); } Task::none() } Message::ScanGames(lname) => { if let Some(l) = state.config.find(&lname) { let l = l.clone(); let lname2 = lname.clone(); state.scan_busy.insert(lname); return Task::perform( async_blocking(move || detect::scan_games_in_prefix(&l)), move |hits| Message::ScanGamesDone(lname2.clone(), hits), ); } Task::none() } Message::ScanGamesDone(lname, hits) => { state.scan_busy.remove(&lname); state.scan_results.insert(lname, hits); Task::none() } Message::AddScannedGame(lname, display, exe) => { state.last_error = None; match state.config.add_game( &lname, display.to_lowercase().replace(' ', "_"), Some(display), PathBuf::from(&exe), false, false, None, ) { Ok(()) => { // Remove this exe from scan results so it disappears if let Some(results) = state.scan_results.get_mut(&lname) { results.retain(|(_, e)| e != &exe); } } Err(e) => state.last_error = Some(format!("Add game failed: {e}")), } Task::none() } Message::BrowseGameExe(lname) => { let start = state .config .find(&lname) .map(|l| l.prefix_dir.join("drive_c").to_string_lossy().into_owned()) .unwrap_or_default(); let lname2 = lname.clone(); Task::perform( async_blocking(move || pick_file("Select game executable", &start)), move |res| Message::BrowseGameExeDone(lname2.clone(), res), ) } Message::BrowseGameExeDone(lname, path_opt) => { if let (Some(path_str), Some(l)) = (path_opt, state.config.find(&lname)) { let drive_c = l.prefix_dir.join("drive_c"); let abs = PathBuf::from(&path_str); let rel = abs .strip_prefix(&drive_c) .map(|r| r.to_string_lossy().to_string()) .unwrap_or(path_str); let display = abs .file_stem() .and_then(|s| s.to_str()) .unwrap_or("game") .to_string(); let entry = state.adding_game.entry(lname).or_default(); if entry.0.is_empty() { entry.0 = display; } entry.1 = rel; } Task::none() } // ── Settings ─────────────────────────────────────────────────────── Message::ShowSettings => { state.settings_open = true; state.settings_proton_version = state.config.proton_version.clone(); state.settings_compat_dir = state.config.proton_compat_dir.to_string_lossy().into_owned(); state.proton_versions = proton::list_installed(&state.config); state.service_status = String::new(); Task::none() } Message::HideSettings => { state.settings_open = false; Task::none() } Message::SettingsProtonVersionChanged(v) => { state.settings_proton_version = v; Task::none() } Message::SettingsCompatDirChanged(v) => { state.settings_compat_dir = v; Task::none() } Message::BrowseCompatDir => Task::perform( async_blocking(|| pick_folder("Choose GE-Proton compat directory")), Message::BrowseCompatDirDone, ), Message::BrowseCompatDirDone(path) => { if let Some(p) = path { state.settings_compat_dir = p; state.proton_versions = proton::list_installed_from(PathBuf::from(&state.settings_compat_dir)); } Task::none() } Message::SaveSettings => { state.last_error = None; let version = state.settings_proton_version.trim().to_string(); let compat = PathBuf::from(state.settings_compat_dir.trim()); // Validate compat dir if !compat.is_absolute() { state.last_error = Some("Compat directory must be an absolute path.".into()); return Task::none(); } // Validate proton version: accept "GE-Proton" (latest) or a // specific version like "GE-Proton10-1". let version_key = if version == "GE-Proton (latest)" { "GE-Proton".to_string() } else { version.clone() }; if version_key.is_empty() || !version_key.starts_with("GE-Proton") { state.last_error = Some(format!("Unknown Proton version \"{version_key}\". Expected \"GE-Proton\" or a specific version like \"GE-Proton10-1\".")); return Task::none(); } match state.config.set_globals(Some(version_key), Some(compat)) { Ok(()) => { state.proton_versions = proton::list_installed(&state.config); state.service_status = "Settings saved.".into(); } Err(e) => { state.last_error = Some(format!("Save failed: {e}")); } } Task::none() } Message::LaunchProtontricks => { std::thread::spawn(|| { let _ = std::process::Command::new("protontricks") .arg("--gui") .spawn(); }); Task::none() } Message::ServiceInstall => { state.service_busy = true; state.service_status = "Installing autostart…".into(); Task::perform( async_blocking(|| service::install().map_err(|e| e.to_string())), Message::ServiceActionDone, ) } Message::ServiceUninstall => { state.service_busy = true; state.service_status = "Removing autostart…".into(); Task::perform( async_blocking(|| service::uninstall().map_err(|e| e.to_string())), Message::ServiceActionDone, ) } Message::ServiceActionDone(res) => { state.service_busy = false; match res { Ok(()) => { state.service_status = if service_is_installed() { "Autostart enabled — starts on next login.".into() } else { "Autostart removed.".into() }; } Err(e) => { state.service_status = format!("Failed: {e}"); } } Task::none() } // ── Close dialog ────────────────────────────────────────────────── Message::CloseRequested(id) => { state.close_dialog_open = true; let _ = id; Task::none() } Message::ConfirmQuit => { if let Ok(mut a) = state.close_action.lock() { *a = Some(CloseAction::Quit); } iced::window::get_oldest().and_then(iced::window::close) } Message::ConfirmMinimize => { if let Ok(mut a) = state.close_action.lock() { *a = Some(CloseAction::MinimizeToTray); } iced::window::get_oldest().and_then(iced::window::close) } Message::CancelClose => { state.close_dialog_open = false; Task::none() } } } fn toggle_flag( config: &mut Config, lname: &str, gname: &str, f: impl FnOnce(&mut crate::config::Game), ) { for l in config.launchers.iter_mut() { if l.name == lname { for g in l.games.iter_mut() { if g.name == gname { f(g); break; } } break; } } let _ = config.save(); } fn service_is_installed() -> bool { dirs::home_dir() .is_some_and(|h| h.join(".config/autostart/umutray.desktop").exists()) } fn subscription(_: &Dashboard) -> Subscription { Subscription::batch([ iced::time::every(Duration::from_secs(2)).map(|_| Message::PollProcesses), iced::time::every(Duration::from_secs(5)).map(|_| Message::ReloadConfig), iced::window::close_requests().map(Message::CloseRequested), ]) } // Palette constants, icon(), section_heading(), btn_accent(), btn_ghost(), // btn_danger() are imported from crate::theme. // ── Top-level view ───────────────────────────────────────────────────────── fn view(state: &Dashboard) -> Element<'_, Message> { if state.close_dialog_open { return view_close_dialog(); } if state.settings_open { return view_settings(state); } // ── Title bar ───────────────────────────────────────────────────────── let settings_btn = button(icon("\u{f3e2}", 16)) .on_press(Message::ShowSettings) .style(btn_ghost) .padding([6, 10]); let title = container( row![ column![ text("umutray").size(26).style(|_: &Theme| text::Style { color: Some(ACCENT), }), text("Wine Launcher Manager").size(11).style(|_: &Theme| text::Style { color: Some(DIM), }), ].spacing(2), iced::widget::horizontal_space(), settings_btn, ] .align_y(Alignment::Center), ) .padding(Padding { top: 20.0, right: 24.0, bottom: 6.0, left: 24.0 }); // ── Accent bar under title ──────────────────────────────────────────── let accent_bar = container(Space::new(0, 0)) .width(Length::Fill) .height(Length::Fixed(2.0)) .style(|_: &Theme| container::Style { background: Some(Background::Color(Color { a: 0.25, ..ACCENT })), ..Default::default() }); let add_btn = button( row![icon("\u{f64d}", 12), text(" Add Launcher").size(12)] .align_y(Alignment::Center) .spacing(4), ) .on_press(Message::AddLauncher) .style(btn_ghost) .padding([6, 14]); if state.config.launchers.is_empty() { let body = column![ title, accent_bar, container( column![ Space::new(0, 40), text("No launchers configured").size(16).style(|_: &Theme| text::Style { color: Some(DIM), }), text("Add a launcher to get started.").size(12).style(|_: &Theme| text::Style { color: Some(MUTED), }), Space::new(0, 12), add_btn, ] .spacing(6) .align_x(Alignment::Center), ) .width(Length::Fill) .center_x(Length::Fill), ]; return container(body) .width(Length::Fill) .height(Length::Fill) .style(surface_bg) .into(); } // ── Launcher cards ──────────────────────────────────────────────────── let mut cards: Vec> = Vec::new(); for l in &state.config.launchers { let installed = l.full_exe_path().exists(); let running = *state.running.get(&l.name).unwrap_or(&false); let diagnose_open = state.diagnose_open.as_deref() == Some(&l.name); let menu_open = state.context_menu.as_deref() == Some(&l.name); let card_inner: Element = if diagnose_open { let checks = state .diagnose_result .as_ref() .filter(|(n, _)| n == &l.name) .map(|(_, v)| v.as_slice()); diagnose_card(l, checks) } else if menu_open { context_menu_card(l) } else { launcher_card( l, installed, running, state.launch_busy.contains(&l.name), state.adding_game.get(&l.name), state.scan_results.get(&l.name), state.scan_busy.contains(&l.name), ) }; let card = mouse_area( container(card_inner) .padding([16, 20]) .style(card_style) .width(Length::Fill), ) .on_right_press(Message::ShowContextMenu(l.name.clone())); cards.push(card.into()); } // ── Footer ──────────────────────────────────────────────────────────── let proton_btn = button( row![ icon(if state.proton_busy { "\u{f130}" } else { "\u{f1c8}" }, 12), text(if state.proton_busy { " Updating…" } else { " GE-Proton" }).size(11), ].align_y(Alignment::Center).spacing(3), ) .on_press_maybe((!state.proton_busy).then_some(Message::UpdateProton)) .style(btn_ghost) .padding([5, 12]); let detect_btn = button( row![ icon(if state.detect_busy { "\u{f130}" } else { "\u{f52a}" }, 12), text(if state.detect_busy { " Scanning…" } else { " Detect" }).size(11), ].align_y(Alignment::Center).spacing(3), ) .on_press_maybe((!state.detect_busy).then_some(Message::DetectPressed)) .style(btn_ghost) .padding([5, 12]); let footer_status: Element = { let txt = if !state.proton_status.is_empty() { state.proton_status.clone() } else if !state.detect_result.is_empty() { state.detect_result.clone() } else { String::new() }; text(txt).size(10).style(|_: &Theme| text::Style { color: Some(DIM) }).into() }; let footer = container( row![ detect_btn, proton_btn, add_btn, iced::widget::horizontal_space(), footer_status, ] .align_y(Alignment::Center) .spacing(6), ) .padding(Padding { top: 10.0, right: 24.0, bottom: 14.0, left: 24.0 }) .style(|_: &Theme| container::Style { background: Some(Background::Color(Color { r: 0.10, g: 0.11, b: 0.13, a: 1.0 })), border: Border { color: BORDER_CLR, width: 0.0, radius: 0.0.into(), }, ..Default::default() }); let cards_col = Column::with_children(cards) .spacing(10) .padding(Padding { top: 12.0, right: 24.0, bottom: 12.0, left: 24.0, }) .width(Length::Fill); let error_bar: Element = if let Some(err) = &state.last_error { container( row![ icon("\u{f33b}", 13) .style(|_: &Theme| text::Style { color: Some(RED) }), text(format!(" {err}")).size(12) .style(|_: &Theme| text::Style { color: Some(RED) }), ] .align_y(Alignment::Center), ) .padding([6, 24]) .width(Length::Fill) .style(|_: &Theme| container::Style { background: Some(Background::Color(Color { r: 0.97, g: 0.44, b: 0.44, a: 0.08 })), ..Default::default() }) .into() } else { Space::new(0, 0).into() }; let body = column![ title, accent_bar, scrollable(cards_col).height(Length::Fill), error_bar, footer, ]; container(body) .width(Length::Fill) .height(Length::Fill) .style(surface_bg) .into() } // ── Card views ────────────────────────────────────────────────────────────── /// Styled inner container for sub-sections inside a card. fn sub_card<'a>(content: impl Into>) -> Element<'a, Message> { container(content) .padding(Padding::from([10, 14])) .width(Length::Fill) .style(sub_card_style) .into() } fn launcher_card<'a>( l: &'a crate::config::Launcher, installed: bool, running: bool, launching: bool, add_form: Option<&'a (String, String)>, scan_results: Option<&'a Vec<(String, String)>>, scan_busy: bool, ) -> Element<'a, Message> { let status_color = if running { GREEN } else if launching || installed { ACCENT } else { MUTED }; let status_icon = if running { "\u{f287}" } else if launching { "\u{f130}" } else if installed { "\u{f26a}" } else { "\u{f28a}" }; let status_str = if running { "Running" } else if launching { "Launching…" } else if installed { "Ready" } else { "Not installed" }; // ── Action button ───────────────────────────────────────────────────── let action: Element = { let n = l.name.clone(); if !installed { button( row![icon("\u{f3e2}", 13), text(" Setup").size(12)] .align_y(Alignment::Center).spacing(4), ) .on_press(Message::Setup(n)) .style(btn_ghost) .padding([7, 14]) .into() } else if launching { button( row![icon("\u{f130}", 13), text(" Launching…").size(12)] .align_y(Alignment::Center).spacing(4), ) .style(btn_ghost) .padding([7, 14]) .into() } else if running { button( row![icon("\u{f5de}", 13), text(" Stop").size(12)] .align_y(Alignment::Center).spacing(4), ) .on_press(Message::Kill(n)) .style(btn_danger) .padding([7, 14]) .into() } else { button( row![icon("\u{f4f4}", 13), text(" Launch").size(12)] .align_y(Alignment::Center).spacing(4), ) .on_press(Message::Launch(n)) .style(btn_accent) .padding([7, 14]) .into() } }; // ── Proton version badge ────────────────────────────────────────────── let version_badge: Element = if let Some(v) = &l.proton_version { container( text(v).size(10).style(move |_: &Theme| text::Style { color: Some(ACCENT), }) ) .padding(Padding::from([3, 8])) .style(|_: &Theme| container::Style { background: Some(Background::Color(Color { r: 0.49, g: 0.55, b: 0.97, a: 0.10 })), border: Border { color: Color { a: 0.20, ..ACCENT }, width: 1.0, radius: 10.0.into() }, ..Default::default() }) .into() } else { Space::new(0, 0).into() }; // ── Status pill ─────────────────────────────────────────────────────── let status_pill = container( row![ icon(status_icon, 9).style(move |_: &Theme| text::Style { color: Some(status_color) }), text(format!(" {status_str}")).size(10) .style(move |_: &Theme| text::Style { color: Some(status_color) }), ].align_y(Alignment::Center), ) .padding(Padding::from([3, 8])) .style(move |_: &Theme| container::Style { background: Some(Background::Color(Color { a: 0.10, ..status_color })), border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 10.0.into() }, ..Default::default() }); // ── Header ──────────────────────────────────────────────────────────── let header = row![ column![ row![ text(&l.display).size(18), version_badge, ].align_y(Alignment::Center).spacing(8), row![ status_pill, ].align_y(Alignment::Center), ].spacing(6), iced::widget::horizontal_space(), action, ] .align_y(Alignment::Center); let mut sections: Vec> = vec![header.into()]; // ── Games section ───────────────────────────────────────────────────── let has_games = !l.games.is_empty(); let has_scan = scan_results.is_some_and(|r| !r.is_empty()); if has_games || has_scan || scan_busy { let game_count = l.games.len(); let section_label: String = if !has_games { "DETECTED".to_string() } else if game_count == 1 { "1 GAME".to_string() } else { format!("{game_count} GAMES") }; let lname_scan = l.name.clone(); let rescan_btn = button(icon("\u{f130}", 11)) .on_press_maybe((!scan_busy).then_some(Message::ScanGames(lname_scan))) .style(btn_ghost) .padding([4, 7]); let section_header = row![ text(section_label).size(10) .style(move |_: &Theme| text::Style { color: Some(DIM) }), iced::widget::horizontal_space(), rescan_btn, ] .align_y(Alignment::Center); let mut game_items: Vec> = vec![section_header.into()]; // Scanning indicator if scan_busy { game_items.push( container( row![ icon("\u{f130}", 12) .style(move |_: &Theme| text::Style { color: Some(ACCENT) }), text(" Scanning for games…").size(12) .style(move |_: &Theme| text::Style { color: Some(ACCENT) }), ].align_y(Alignment::Center), ) .padding(Padding::from([8, 0])) .into(), ); } // Detected (unregistered) games if let Some(results) = scan_results { for (display, exe) in results { let lname = l.name.clone(); let d = display.clone(); let e = exe.clone(); let add_btn = button( row![icon("\u{f64d}", 11), text(" Add").size(11)] .align_y(Alignment::Center).spacing(3), ) .on_press(Message::AddScannedGame(lname, d, e)) .style(btn_accent) .padding([5, 10]); game_items.push(sub_card( row![ text("◇").size(10).style(move |_: &Theme| text::Style { color: Some(DIM) }), column![ text(display).size(13), text(exe).size(9).style(move |_: &Theme| text::Style { color: Some(MUTED) }), ].spacing(2), iced::widget::horizontal_space(), add_btn, ] .align_y(Alignment::Center) .spacing(10), )); } if results.is_empty() && !scan_busy { game_items.push( container( text("No additional games detected.").size(11) .style(move |_: &Theme| text::Style { color: Some(MUTED) }) ) .padding(Padding::from([6, 0])) .into(), ); } } // Configured games for g in &l.games { let lname = l.name.clone(); let gname = g.name.clone(); let game_installed = g.full_exe_path(l).exists(); // Play button let play = button(icon("\u{f4f4}", 14)) .on_press_maybe(game_installed.then_some(Message::Play(lname.clone(), gname.clone()))) .style(btn_accent) .padding([7, 11]); // Status dot let status_dot_color = if game_installed { GREEN } else { MUTED }; let status_dot = text("●").size(7).style(move |_: &Theme| text::Style { color: Some(status_dot_color), }); // Overlay toggle pills let gm_active = g.gamemode; let hud_active = g.mangohud; let gs_active = g.gamescope.is_some(); let pill = |label: &'static str, active: bool, msg: Message| -> Element<'a, Message> { let (fg, bg_alpha, border_alpha) = if active { (ACCENT, 0.15, 0.30) } else { (MUTED, 0.0, 0.10) }; button( text(label).size(9).style(move |_: &Theme| text::Style { color: Some(fg) }) ) .on_press(msg) .style(move |_: &Theme, _status| button::Style { background: Some(Background::Color(Color { r: 0.49, g: 0.55, b: 0.97, a: bg_alpha })), text_color: fg, border: Border { color: Color { a: border_alpha, ..ACCENT }, width: 1.0, radius: 10.0.into(), }, shadow: NO_SHADOW, }) .padding([3, 9]) .into() }; let overlays = row![ pill("GM", gm_active, Message::ToggleGameMode(lname.clone(), gname.clone())), pill("HUD", hud_active, Message::ToggleMangoHud(lname.clone(), gname.clone())), pill("GS", gs_active, Message::ToggleGamescope(lname.clone(), gname.clone())), ].spacing(4); // Remove let remove_game = button(icon("\u{f659}", 11)) .on_press(Message::RemoveGame(lname, gname)) .style(btn_danger) .padding([4, 7]); game_items.push(sub_card( row![ play, container(status_dot).padding(Padding { top: 0.0, left: 2.0, right: 2.0, bottom: 0.0 }), text(&g.display).size(14), iced::widget::horizontal_space(), overlays, container(remove_game).padding(Padding { top: 0.0, left: 8.0, right: 0.0, bottom: 0.0 }), ] .align_y(Alignment::Center) .spacing(8), )); } sections.push( Column::with_children(game_items).spacing(6).into(), ); } else { // Empty state let lname = l.name.clone(); let lname2 = l.name.clone(); let scan_btn = button( row![icon("\u{f130}", 11), text(" Scan for games").size(11)] .align_y(Alignment::Center).spacing(4), ) .on_press(Message::ScanGames(lname)) .style(btn_ghost) .padding([5, 12]); let browse_btn = button( row![icon("\u{f3e8}", 11), text(" Browse…").size(11)] .align_y(Alignment::Center).spacing(4), ) .on_press(Message::BrowseGameExe(lname2)) .style(btn_ghost) .padding([5, 12]); sections.push(sub_card( row![ text("No games configured").size(12) .style(move |_: &Theme| text::Style { color: Some(MUTED) }), iced::widget::horizontal_space(), scan_btn, browse_btn, ] .align_y(Alignment::Center) .spacing(6), )); } // ── Add game form ───────────────────────────────────────────────────── if let Some((name_val, exe_val)) = add_form { let lname = l.name.clone(); let lname2 = l.name.clone(); let lname3 = l.name.clone(); let lname4 = l.name.clone(); let lname5 = l.name.clone(); let name_input = text_input("Game name", name_val) .on_input(move |v| Message::AddGameNameChanged(lname.clone(), v)) .padding(8) .size(12) .width(Length::FillPortion(2)); let exe_input = text_input("Exe path (relative to drive_c/)", exe_val) .on_input(move |v| Message::AddGameExeChanged(lname2.clone(), v)) .padding(8) .size(12) .width(Length::FillPortion(3)); let browse_btn = button(text("Browse…").size(11)) .on_press(Message::BrowseGameExe(lname5)) .style(btn_ghost) .padding([6, 10]); let can_confirm = !name_val.trim().is_empty() && !exe_val.trim().is_empty(); let confirm_btn = button( row![icon("\u{f64d}", 11), text(" Add Game").size(11)] .align_y(Alignment::Center).spacing(3), ) .on_press_maybe(can_confirm.then_some(Message::AddGameConfirm(lname3))) .style(btn_accent) .padding([6, 12]); let cancel_btn = button(text("Cancel").size(11)) .on_press(Message::AddGamePressed(lname4)) .style(btn_ghost) .padding([6, 10]); sections.push(sub_card( column![ section_heading("Add Game"), row![name_input, exe_input, browse_btn] .align_y(Alignment::Center) .spacing(6), container( row![cancel_btn, confirm_btn].spacing(6), ) .width(Length::Fill) .align_x(Alignment::End), ] .spacing(8), )); } // ── Toolbar ─────────────────────────────────────────────────────────── let lname_add = l.name.clone(); let lname_scan = l.name.clone(); let lname_browse = l.name.clone(); let add_label = if add_form.is_some() { "Cancel" } else { "+ Add" }; let add_game_btn = button(text(add_label).size(10)) .on_press(Message::AddGamePressed(lname_add)) .style(btn_ghost) .padding([4, 9]); let scan_btn = button(icon("\u{f130}", 10)) .on_press_maybe((!scan_busy).then_some(Message::ScanGames(lname_scan))) .style(btn_ghost) .padding([4, 7]); let browse_btn = button(icon("\u{f3e8}", 10)) .on_press(Message::BrowseGameExe(lname_browse)) .style(btn_ghost) .padding([4, 7]); sections.push( row![ add_game_btn, iced::widget::horizontal_space(), scan_btn, browse_btn, ] .align_y(Alignment::Center) .spacing(4) .into(), ); Column::with_children(sections).spacing(12).into() } fn context_menu_card(l: &crate::config::Launcher) -> Element<'_, Message> { let header = row![ column![ text(&l.display).size(16), text("ACTIONS").size(10).style(|_: &Theme| text::Style { color: Some(DIM), }), ] .spacing(3), iced::widget::horizontal_space(), button(icon("\u{f659}", 13)) .on_press(Message::HideContextMenu) .style(btn_ghost) .padding([5, 8]), ] .align_y(Alignment::Center); let menu_btn = |label: &str, ico: &str, msg: Message| -> Element<'_, Message> { button( row![ icon(ico, 13), text(format!(" {label}")).size(13), ].align_y(Alignment::Center).spacing(6), ) .on_press(msg) .style(btn_ghost) .padding([8, 14]) .width(Length::Fill) .into() }; let remove = button( row![ icon("\u{f659}", 13), text(" Remove launcher").size(13), ].align_y(Alignment::Center).spacing(6), ) .on_press(Message::RemoveLauncher(l.name.clone())) .style(btn_danger) .padding([8, 14]) .width(Length::Fill); column![ header, Space::new(0, 4), menu_btn("Open install folder", "\u{f3e8}", Message::OpenPrefix(l.name.clone())), menu_btn("Re-run setup wizard", "\u{f130}", Message::RerunSetup(l.name.clone())), menu_btn("Run diagnostics", "\u{f52a}", Message::DiagnosePressed(l.name.clone())), Space::new(0, 2), remove, ] .spacing(2) .into() } fn diagnose_card<'a>( l: &'a crate::config::Launcher, checks: Option<&'a [diagnose::CheckResult]>, ) -> Element<'a, Message> { let header = row![ column![ text("Diagnostics").size(16), text(&l.display).size(11).style(|_: &Theme| text::Style { color: Some(ACCENT), }), ] .spacing(3), iced::widget::horizontal_space(), button(icon("\u{f659}", 13)) .on_press(Message::HideDiagnose) .style(btn_ghost) .padding([5, 8]), ] .align_y(Alignment::Center); let body: Element = match checks { None => container( row![ icon("\u{f130}", 13).style(|_: &Theme| text::Style { color: Some(ACCENT) }), text(" Running checks…").size(12).style(|_: &Theme| text::Style { color: Some(ACCENT), }), ].align_y(Alignment::Center), ) .padding([8, 0]) .into(), Some(results) => { let rows: Vec> = results .iter() .map(|c| { let (sym, color) = if c.pass { ("\u{f26a}", GREEN) // check-circle } else { ("\u{f28a}", RED) // x-circle }; sub_card( row![ icon(sym, 13).style(move |_: &Theme| text::Style { color: Some(color), }), text(format!(" {}", c.label)).size(12), iced::widget::horizontal_space(), text(&c.detail).size(11).style(|_: &Theme| text::Style { color: Some(DIM), }), ] .align_y(Alignment::Center), ) }) .collect(); scrollable(Column::with_children(rows).spacing(4)) .height(Length::Fixed(180.0)) .into() } }; column![ header, Space::new(0, 6), body, ] .spacing(4) .into() } /// Styled section card for the settings page. fn settings_section<'a>(title: &str, content: Element<'a, Message>) -> Element<'a, Message> { container( column![ text(title.to_uppercase()).size(10).style(|_: &Theme| text::Style { color: Some(DIM), }), Space::new(0, 4), content, ] .spacing(2), ) .padding([14, 18]) .width(Length::Fill) .style(card_style) .into() } // ── Close dialog ──────────────────────────────────────────────────────────── fn view_close_dialog() -> Element<'static, Message> { use crate::theme::card_style; let title_row = row![ icon("\u{f623}", 18) .style(|_: &Theme| text::Style { color: Some(ACCENT) }), text(" Close umutray?").size(16), ] .align_y(Alignment::Center); let description = text("Choose what happens when you close the window.") .size(12) .style(|_: &Theme| text::Style { color: Some(DIM) }); let minimize_btn = button( row![ icon("\u{f393}", 14), text(" Minimize to Tray").size(13), ] .align_y(Alignment::Center), ) .on_press(Message::ConfirmMinimize) .style(btn_accent) .padding([10, 20]) .width(Length::Fill); let quit_btn = button( row![ icon("\u{f1c3}", 14), text(" Quit").size(13), ] .align_y(Alignment::Center), ) .on_press(Message::ConfirmQuit) .style(btn_danger) .padding([10, 20]) .width(Length::Fill); let cancel_btn = button(text("Cancel").size(12)) .on_press(Message::CancelClose) .style(btn_ghost) .padding([8, 16]) .width(Length::Fill); let dialog = container( column![ title_row, description, Space::new(0, 8), minimize_btn, quit_btn, cancel_btn, ] .spacing(10) .width(Length::Fixed(280.0)), ) .padding(24) .style(card_style); // Centre the dialog on a dark backdrop. container( container(dialog) .center_x(Length::Fill) .center_y(Length::Fill), ) .width(Length::Fill) .height(Length::Fill) .style(|_: &Theme| container::Style { background: Some(Background::Color(Color { r: 0.0, g: 0.0, b: 0.0, a: 0.65 })), ..Default::default() }) .into() } // ── Settings view ─────────────────────────────────────────────────────────── fn view_settings(state: &Dashboard) -> Element<'_, Message> { let header = row![ row![ icon("\u{f3e2}", 20).style(|_: &Theme| text::Style { color: Some(ACCENT) }), text(" Settings").size(22), ].align_y(Alignment::Center), iced::widget::horizontal_space(), button( row![icon("\u{f12f}", 13), text(" Back").size(12)] .align_y(Alignment::Center).spacing(4), ) .on_press(Message::HideSettings) .style(btn_ghost) .padding([6, 14]), ] .align_y(Alignment::Center); // ── Proton section ──────────────────────────────────────────────────── let proton_version_picker = pick_list( state.proton_versions.as_slice(), Some(if state.settings_proton_version == "GE-Proton" { "GE-Proton (latest)".to_string() } else { state.settings_proton_version.clone() }), Message::SettingsProtonVersionChanged, ) .width(Length::Fill); let compat_dir_input = text_input( "e.g. ~/.local/share/Steam/compatibilitytools.d", &state.settings_compat_dir, ) .on_input(Message::SettingsCompatDirChanged) .padding(10) .width(Length::Fill); let browse_compat_btn = button(text("Browse…").size(12)) .on_press(Message::BrowseCompatDir) .style(btn_ghost) .padding([8, 14]); let save_btn = button( row![icon("\u{f26a}", 12), text(" Save").size(12)] .align_y(Alignment::Center).spacing(4), ) .on_press(Message::SaveSettings) .style(btn_accent) .padding([8, 18]); let proton_section = settings_section("Proton", column![ text("Version").size(12).style(|_: &Theme| text::Style { color: Some(DIM) }), proton_version_picker, Space::new(0, 4), text("Compat directory").size(12).style(|_: &Theme| text::Style { color: Some(DIM) }), row![compat_dir_input, browse_compat_btn].spacing(8).align_y(Alignment::Center), Space::new(0, 4), container(save_btn).width(Length::Fill).align_x(Alignment::End), ].spacing(6).into(), ); // ── Tools section ───────────────────────────────────────────────────── let tools_section = settings_section("Tools", column![ button( row![icon("\u{f3e2}", 13), text(" Launch Protontricks").size(12)] .align_y(Alignment::Center).spacing(6), ) .on_press(Message::LaunchProtontricks) .style(btn_ghost) .padding([8, 14]), ].into(), ); // ── Autostart section ───────────────────────────────────────────────── let installed = service_is_installed(); let svc_status_color = if installed { GREEN } else { MUTED }; let svc_status_text = if installed { "Enabled — starts on login" } else { "Disabled" }; let svc_status_icon = if installed { "\u{f26a}" } else { "\u{f28a}" }; let svc_install_btn = button( row![icon("\u{f64d}", 12), text(if state.service_busy { " Working…" } else { " Enable" }).size(12)] .align_y(Alignment::Center).spacing(4), ) .on_press_maybe((!state.service_busy && !installed).then_some(Message::ServiceInstall)) .style(btn_accent) .padding([7, 14]); let svc_uninstall_btn = button( row![icon("\u{f659}", 12), text(if state.service_busy { " Working…" } else { " Disable" }).size(12)] .align_y(Alignment::Center).spacing(4), ) .on_press_maybe((!state.service_busy && installed).then_some(Message::ServiceUninstall)) .style(btn_danger) .padding([7, 14]); let autostart_section = settings_section("Autostart", column![ row![ icon(svc_status_icon, 13).style(move |_: &Theme| text::Style { color: Some(svc_status_color) }), text(format!(" {svc_status_text}")).size(12).style(move |_: &Theme| text::Style { color: Some(svc_status_color), }), iced::widget::horizontal_space(), svc_install_btn, svc_uninstall_btn, ].align_y(Alignment::Center).spacing(8), text(&state.service_status).size(11).style(|_: &Theme| text::Style { color: Some(DIM), }), ].spacing(6).into(), ); // ── Error bar ───────────────────────────────────────────────────────── let error_el: Element = if let Some(err) = &state.last_error { container( row![ icon("\u{f33b}", 13) .style(|_: &Theme| text::Style { color: Some(RED) }), text(format!(" {err}")).size(12) .style(|_: &Theme| text::Style { color: Some(RED) }), ].align_y(Alignment::Center), ) .padding([8, 18]) .width(Length::Fill) .style(|_: &Theme| container::Style { background: Some(Background::Color(Color { a: 0.08, ..RED })), border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 8.0.into() }, ..Default::default() }) .into() } else { Space::new(0, 0).into() }; let body = column![ header, Space::new(0, 6), proton_section, tools_section, autostart_section, error_el, ] .spacing(12) .padding(24); container(scrollable(body)) .width(Length::Fill) .height(Length::Fill) .style(surface_bg) .into() } pub fn run(config: &Config) -> Result { let config = config.clone(); let close_action: Arc>> = Arc::new(Mutex::new(None)); let ca = close_action.clone(); iced::application(|_: &Dashboard| String::from("umutray"), update, view) .subscription(subscription) .theme(|_| Theme::Dark) .window(iced::window::Settings { size: iced::Size::new(640.0, 600.0), min_size: Some(iced::Size::new(480.0, 400.0)), exit_on_close_request: false, ..Default::default() }) .run_with(move || { let cfg = config.clone(); let load_font = iced::font::load(std::borrow::Cow::Borrowed(iced_fonts::BOOTSTRAP_FONT_BYTES)).map(|_| Message::FontLoaded); (Dashboard::new(cfg, ca.clone()), load_font) }) .map_err(|e| anyhow::anyhow!("iced: {e}"))?; let action = close_action.lock().unwrap().unwrap_or(CloseAction::Quit); Ok(action) }