use crate::{config::Config, detect, diagnose, launcher, proton, service, 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, Column, }; use iced::{ Alignment, Background, Border, Color, Element, Length, Padding, Subscription, Task, Theme, }; use std::collections::HashMap; use std::path::PathBuf; use std::time::Duration; #[derive(Debug, Clone)] pub enum Message { PollProcesses, ReloadConfig, AddLauncher, Launch(String), Kill(String), Play(String, 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>), } struct Dashboard { config: Config, running: HashMap, proton_busy: bool, proton_status: String, last_error: Option, context_menu: Option, // 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, } impl Dashboard { fn new(config: Config) -> 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, 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(), } } } fn update(state: &mut Dashboard, msg: Message) -> Task { match msg { Message::PollProcesses => { for l in &state.config.launchers { state.running.insert(l.name.clone(), launcher::is_running(l)); } 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.running.insert(name.clone(), true); let Some(l) = state.config.find(&name) else { return Task::none(); }; let config = state.config.clone(); let l = l.clone(); std::thread::spawn(move || { if let Err(e) = launcher::launch(&config, &l) { eprintln!("umutray: launch {name} 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).map_err(|e| e.to_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(); std::thread::spawn(move || { if let Err(e) = launcher::play_game(&config, &l, &g) { eprintln!("umutray: play {gname} 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!("{} (not in config — use Setup to add)", h.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) => { if state.adding_game.contains_key(&lname) { state.adding_game.remove(&lname); } else { state.adding_game.insert(lname, (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 format (allow "GE-Proton (latest)" or "GE-Proton\d+-\d+") let version_key = if version == "GE-Proton (latest)" { "GE-Proton".to_string() } else { version.clone() }; let valid_version = version_key == "GE-Proton" || version_key.starts_with("GE-Proton"); if !valid_version { 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::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() } } } 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() .map(|h| h.join(".config/autostart/umutray.desktop").exists()) .unwrap_or(false) } 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), ]) } // ── Top-level view ───────────────────────────────────────────────────────── fn view(state: &Dashboard) -> Element<'_, Message> { if state.settings_open { return view_settings(state); } let settings_btn = button(text("\u{F3F8}").font(iced::Font::with_name("bootstrap-icons")).size(16)) .on_press(Message::ShowSettings) .style(button::secondary); let title = container( row![ text("umutray").size(28), iced::widget::horizontal_space(), settings_btn, ] .align_y(Alignment::Center), ) .padding(Padding { top: 16.0, right: 20.0, bottom: 8.0, left: 20.0 }); let add_btn = button(text("+ Add Launcher").size(13)) .on_press(Message::AddLauncher) .style(button::secondary); if state.config.launchers.is_empty() { let body = column![ title, container( column![text("No launchers configured.").size(15), add_btn,] .spacing(12) .padding([20, 20]), ) .width(Length::Fill), ]; return container(body) .width(Length::Fill) .height(Length::Fill) .into(); } 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.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([10, 14]) .style(|theme: &Theme| { let p = theme.extended_palette(); container::Style { background: Some(Background::Color(p.background.weak.color)), border: Border { color: p.background.strong.color, width: 1.0, radius: 6.0.into(), }, ..Default::default() } }) .width(Length::Fill), ) .on_right_press(Message::ShowContextMenu(l.name.clone())); cards.push(card.into()); } let proton_btn = button( text(if state.proton_busy { "Updating…" } else { "Update GE-Proton" }).size(13), ) .on_press_maybe((!state.proton_busy).then_some(Message::UpdateProton)) .style(button::secondary); let detect_btn = button( text(if state.detect_busy { "Scanning…" } else { "Detect Installed" }).size(13), ) .on_press_maybe((!state.detect_busy).then_some(Message::DetectPressed)) .style(button::secondary); let footer = container( column![ row![detect_btn, text(&state.detect_result).size(12),] .align_y(Alignment::Center) .spacing(12), row![proton_btn, text(&state.proton_status).size(12),] .align_y(Alignment::Center) .spacing(12), ] .spacing(6), ) .padding([10, 20]); let cards_col = Column::with_children(cards) .push(container(add_btn).padding(Padding { top: 8.0, right: 0.0, bottom: 0.0, left: 0.0, })) .spacing(8) .padding(Padding { top: 0.0, right: 20.0, bottom: 8.0, left: 20.0, }) .width(Length::Fill); let error_bar: Element = if let Some(err) = &state.last_error { container( text(format!("⚠ {err}")) .size(12) .style(|_: &Theme| text::Style { color: Some(Color::from_rgb(1.0, 0.45, 0.45)), }), ) .padding([4, 20]) .width(Length::Fill) .into() } else { text("").into() }; let body = column![ title, scrollable(cards_col).height(Length::Fill), error_bar, iced::widget::horizontal_rule(1), footer, ]; container(body) .width(Length::Fill) .height(Length::Fill) .into() } // ── Card views ────────────────────────────────────────────────────────────── fn launcher_card<'a>( l: &'a crate::config::Launcher, installed: bool, running: bool, add_form: Option<&'a (String, String)>, scan_results: Option<&'a Vec<(String, String)>>, scan_busy: bool, ) -> Element<'a, Message> { let status_label = if running { "● Running" } else if installed { "○ Installed" } else { "· Not installed" }; let action: Element = { let n = l.name.clone(); if !installed { button(text("Setup").size(13)) .on_press(Message::Setup(n)) .style(button::secondary) .into() } else if running { button(text("Kill").size(13)) .on_press(Message::Kill(n)) .style(button::danger) .into() } else { button(text("Launch").size(13)) .on_press(Message::Launch(n)) .style(button::primary) .into() } }; let version_badge: Element = if let Some(v) = &l.proton_version { text(format!(" [{v}]")) .size(11) .style(|_: &Theme| text::Style { color: Some(Color::from_rgb(0.55, 0.75, 1.0)), }) .into() } else { text("").into() }; let header = row![ text(&l.display).size(15), text(" — ").size(12), text(status_label).size(12), version_badge, iced::widget::horizontal_space(), action, ] .align_y(Alignment::Center); let mut rows: Vec> = vec![header.into()]; if l.games.is_empty() && scan_results.map(|r| r.is_empty()).unwrap_or(true) && !scan_busy { let lname = l.name.clone(); let lname2 = l.name.clone(); let scan_btn = button(text("Scan for games").size(11)) .on_press(Message::ScanGames(lname)) .style(button::secondary); let browse_btn = button(text("Browse exe…").size(11)) .on_press(Message::BrowseGameExe(lname2)) .style(button::secondary); rows.push( row![ text(" ").size(12), text("No games added yet.").size(12).style(|_: &Theme| text::Style { color: Some(Color::from_rgb(0.55, 0.55, 0.55)), }), iced::widget::horizontal_space(), scan_btn, browse_btn, ] .align_y(Alignment::Center) .spacing(6) .into(), ); } if scan_busy { rows.push(text(" Scanning…").size(12).into()); } // Scan results (exes found in prefix, not yet added) 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(text("+").size(11)) .on_press(Message::AddScannedGame(lname, d, e)) .style(button::primary); rows.push( row![ text(" ").size(12), text(format!("⊕ {display}")).size(12).style(|_: &Theme| text::Style { color: Some(Color::from_rgb(0.65, 0.65, 0.65)), }), text(format!(" {exe}")).size(10).style(|_: &Theme| text::Style { color: Some(Color::from_rgb(0.45, 0.45, 0.45)), }), iced::widget::horizontal_space(), add_btn, ] .align_y(Alignment::Center) .spacing(4) .into(), ); } if !results.is_empty() { rows.push( text(" Found in Wine prefix — click + to add") .size(10) .style(|_: &Theme| text::Style { color: Some(Color::from_rgb(0.45, 0.45, 0.45)), }) .into(), ); } else if !scan_busy { rows.push( text(" No new executables found in the Wine prefix.") .size(11) .style(|_: &Theme| text::Style { color: Some(Color::from_rgb(0.45, 0.45, 0.45)), }) .into(), ); } } for g in &l.games { let lname = l.name.clone(); let gname = g.name.clone(); let play = button(text("▶").size(11)) .on_press(Message::Play(lname.clone(), gname.clone())) .style(button::primary); let gamemode_btn = button(text("GameMode").size(11)) .on_press(Message::ToggleGameMode(lname.clone(), gname.clone())) .style(if g.gamemode { button::primary } else { button::secondary }); let mangohud_btn = button(text("MangoHud").size(11)) .on_press(Message::ToggleMangoHud(lname.clone(), gname.clone())) .style(if g.mangohud { button::primary } else { button::secondary }); let gamescope_btn = button(text("Gamescope").size(11)) .on_press(Message::ToggleGamescope(lname.clone(), gname.clone())) .style(if g.gamescope.is_some() { button::primary } else { button::secondary }); let remove_game = button(text("✕").size(10)) .on_press(Message::RemoveGame(lname, gname)) .style(button::danger); rows.push( row![ text(" ").size(13), play, text(&g.display).size(13), iced::widget::horizontal_space(), gamemode_btn, mangohud_btn, gamescope_btn, remove_game, ] .align_y(Alignment::Center) .spacing(6) .into(), ); } // Inline 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(5) .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(5) .size(12) .width(Length::FillPortion(3)); let browse_btn = button(text("Browse…").size(11)) .on_press(Message::BrowseGameExe(lname5)) .style(button::secondary); let can_confirm = !name_val.trim().is_empty() && !exe_val.trim().is_empty(); let confirm_btn = button(text("Add").size(11)) .on_press_maybe(can_confirm.then_some(Message::AddGameConfirm(lname3))) .style(button::primary); let cancel_btn = button(text("Cancel").size(11)) .on_press(Message::AddGamePressed(lname4)) .style(button::secondary); rows.push( row![name_input, exe_input, browse_btn, confirm_btn, cancel_btn] .align_y(Alignment::Center) .spacing(6) .into(), ); } let add_game_btn = { let lname = l.name.clone(); let label = if add_form.is_some() { "− Game" } else { "+ Game" }; button(text(label).size(11)) .on_press(Message::AddGamePressed(lname)) .style(button::secondary) }; rows.push( container(add_game_btn) .padding(Padding { top: 4.0, right: 0.0, bottom: 0.0, left: 0.0 }) .into(), ); Column::with_children(rows).spacing(6).into() } fn context_menu_card(l: &crate::config::Launcher) -> Element<'_, Message> { let header = row![ text(&l.display).size(15), iced::widget::horizontal_space(), button(text("✕").size(12)) .on_press(Message::HideContextMenu) .style(button::secondary), ] .align_y(Alignment::Center); let open_prefix = button(text("Open prefix folder").size(13)) .on_press(Message::OpenPrefix(l.name.clone())) .style(button::secondary) .width(Length::Fill); let rerun_setup = button(text("Re-run setup").size(13)) .on_press(Message::RerunSetup(l.name.clone())) .style(button::secondary) .width(Length::Fill); let diagnose = button(text("Diagnose").size(13)) .on_press(Message::DiagnosePressed(l.name.clone())) .style(button::secondary) .width(Length::Fill); let remove = button(text("Remove launcher").size(13)) .on_press(Message::RemoveLauncher(l.name.clone())) .style(button::danger) .width(Length::Fill); column![ header, iced::widget::horizontal_rule(1), open_prefix, rerun_setup, diagnose, remove, ] .spacing(4) .into() } fn diagnose_card<'a>( l: &'a crate::config::Launcher, checks: Option<&'a [diagnose::CheckResult]>, ) -> Element<'a, Message> { let header = row![ text(format!("Diagnose: {}", l.display)).size(15), iced::widget::horizontal_space(), button(text("✕").size(12)) .on_press(Message::HideDiagnose) .style(button::secondary), ] .align_y(Alignment::Center); let body: Element = match checks { None => text("Running checks…").size(12).into(), Some(results) => { let rows: Vec> = results .iter() .map(|c| { let (sym, color) = if c.pass { ("✓", Color::from_rgb(0.4, 0.9, 0.4)) } else { ("✗", Color::from_rgb(1.0, 0.45, 0.45)) }; row![ text(sym).size(12).style(move |_: &Theme| text::Style { color: Some(color), }), text(format!(" {:24} {}", c.label, c.detail)).size(12), ] .align_y(Alignment::Center) .into() }) .collect(); scrollable(Column::with_children(rows).spacing(3)) .height(Length::Fixed(160.0)) .into() } }; column![header, iced::widget::horizontal_rule(1), body,] .spacing(6) .into() } // ── Settings view ─────────────────────────────────────────────────────────── fn view_settings(state: &Dashboard) -> Element<'_, Message> { let header = row![ row![text("\u{F3F8}").font(iced::Font::with_name("bootstrap-icons")).size(20), text(" Settings").size(24)].align_y(Alignment::Center), iced::widget::horizontal_space(), button(text("← Back").size(13)) .on_press(Message::HideSettings) .style(button::secondary), ] .align_y(Alignment::Center); 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(8) .width(Length::Fill); let browse_compat_btn = button(text("Browse…").size(13)) .on_press(Message::BrowseCompatDir) .style(button::secondary); let save_btn = button(text("Save").size(13)) .on_press(Message::SaveSettings) .style(button::primary); let installed = service_is_installed(); let svc_install_btn = button( text(if state.service_busy { "Working…" } else { "Install autostart service" }).size(13), ) .on_press_maybe((!state.service_busy && !installed).then_some(Message::ServiceInstall)) .style(button::secondary); let svc_uninstall_btn = button( text(if state.service_busy { "Working…" } else { "Remove autostart service" }).size(13), ) .on_press_maybe((!state.service_busy && installed).then_some(Message::ServiceUninstall)) .style(button::danger); let svc_status_label = if installed { "Autostart: enabled (tray starts on login)" } else { "Autostart: disabled" }; let body = column![ header, iced::widget::horizontal_rule(1), text("Proton version").size(13), proton_version_picker, text("GE-Proton compat directory").size(13), row![compat_dir_input, browse_compat_btn].spacing(8).align_y(Alignment::Center), save_btn, iced::widget::horizontal_rule(1), text(svc_status_label).size(13), row![svc_install_btn, svc_uninstall_btn,].spacing(10), text(&state.service_status).size(12), ] .spacing(10) .padding(20); container(body) .width(Length::Fill) .height(Length::Fill) .into() } pub fn run(config: &Config) -> Result<()> { let config = config.clone(); iced::application(|_: &Dashboard| String::from("umutray"), update, view) .subscription(subscription) .theme(|_| Theme::Dark) .font(iced_fonts::BOOTSTRAP_FONT_BYTES) .window(iced::window::Settings { size: iced::Size::new(600.0, 560.0), ..Default::default() }) .run_with(move || (Dashboard::new(config.clone()), Task::none())) .map_err(|e| anyhow::anyhow!("iced: {e}")) }