2f4f1c64d2
- Replace .map().unwrap_or(false) with .is_some_and()/.is_ok_and()
- Use path.display() instead of {:?} for user-facing messages
- Replace Option<Option<Vec<String>>> with GamescopeUpdate enum
- Replace manual parent-walking loops with .ancestors() iterators
- Simplify kill()/kill_all() signatures to return () instead of Result
- Use tokio::task::spawn_blocking instead of hand-rolled thread+oneshot
- Read /proc/self/status for UID instead of spawning id subprocess
- Build Exec= line directly in render_desktop instead of string-replace
- Bump PKGBUILD pkgrel to 6
1731 lines
63 KiB
Rust
1731 lines
63 KiB
Rust
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<String, bool>),
|
|
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<detect::DetectHit>),
|
|
// Diagnose
|
|
DiagnosePressed(String),
|
|
DiagnoseDone(String, Vec<diagnose::CheckResult>),
|
|
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<String>),
|
|
// Settings
|
|
ShowSettings,
|
|
HideSettings,
|
|
SettingsProtonVersionChanged(String),
|
|
SettingsCompatDirChanged(String),
|
|
BrowseCompatDir,
|
|
BrowseCompatDirDone(Option<String>),
|
|
SaveSettings,
|
|
ServiceInstall,
|
|
ServiceUninstall,
|
|
ServiceActionDone(Result<(), String>),
|
|
LaunchProtontricks,
|
|
// Close dialog
|
|
CloseRequested(iced::window::Id),
|
|
ConfirmQuit,
|
|
ConfirmMinimize,
|
|
CancelClose,
|
|
}
|
|
|
|
struct Dashboard {
|
|
config: Config,
|
|
running: HashMap<String, bool>,
|
|
proton_busy: bool,
|
|
proton_status: String,
|
|
last_error: Option<String>,
|
|
context_menu: Option<String>,
|
|
/// Launchers currently being launched (show spinner instead of button).
|
|
launch_busy: std::collections::HashSet<String>,
|
|
// Detect
|
|
detect_busy: bool,
|
|
detect_result: String,
|
|
// Diagnose
|
|
diagnose_open: Option<String>,
|
|
diagnose_result: Option<(String, Vec<diagnose::CheckResult>)>,
|
|
// Games
|
|
adding_game: HashMap<String, (String, String)>,
|
|
scan_results: HashMap<String, Vec<(String, String)>>,
|
|
scan_busy: std::collections::HashSet<String>,
|
|
// Settings
|
|
settings_open: bool,
|
|
settings_proton_version: String,
|
|
settings_compat_dir: String,
|
|
proton_versions: Vec<String>,
|
|
service_busy: bool,
|
|
service_status: String,
|
|
// Close dialog
|
|
close_dialog_open: bool,
|
|
close_action: Arc<Mutex<Option<CloseAction>>>,
|
|
}
|
|
|
|
impl Dashboard {
|
|
fn new(config: Config, close_action: Arc<Mutex<Option<CloseAction>>>) -> 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<Message> {
|
|
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<String> = 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<Message> {
|
|
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<Element<Message>> = 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<Message> = 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<Message> = {
|
|
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<Message> = 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>>) -> 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<Message> = {
|
|
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<Message> = 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<Element<Message>> = 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<Element<Message>> = 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<Message> = 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<Element<Message>> = 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<Message> = 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<CloseAction> {
|
|
let config = config.clone();
|
|
let close_action: Arc<Mutex<Option<CloseAction>>> = 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)
|
|
}
|