new file: Makefile
new file: TODO.md modified: src/config.rs modified: src/detect.rs modified: src/diagnose.rs new file: src/gui.rs modified: src/main.rs modified: src/service.rs modified: src/setup.rs modified: src/tray.rs new file: src/util.rs new file: umutray.desktop
This commit is contained in:
+941
@@ -0,0 +1,941 @@
|
||||
use crate::{config::Config, detect, diagnose, launcher, service, util::async_blocking};
|
||||
use anyhow::Result;
|
||||
use iced::widget::{
|
||||
button, column, container, mouse_area, 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>),
|
||||
// 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),
|
||||
// Settings
|
||||
ShowSettings,
|
||||
HideSettings,
|
||||
SettingsProtonVersionChanged(String),
|
||||
SettingsCompatDirChanged(String),
|
||||
SaveSettings,
|
||||
ServiceInstall,
|
||||
ServiceUninstall,
|
||||
ServiceActionDone(Result<(), String>),
|
||||
}
|
||||
|
||||
struct Dashboard {
|
||||
config: Config,
|
||||
running: HashMap<String, bool>,
|
||||
proton_busy: bool,
|
||||
proton_status: String,
|
||||
last_error: Option<String>,
|
||||
context_menu: Option<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)>,
|
||||
// Settings
|
||||
settings_open: bool,
|
||||
settings_proton_version: String,
|
||||
settings_compat_dir: String,
|
||||
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();
|
||||
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(),
|
||||
settings_open: false,
|
||||
settings_proton_version,
|
||||
settings_compat_dir,
|
||||
service_busy: false,
|
||||
service_status: String::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn update(state: &mut Dashboard, msg: Message) -> Task<Message> {
|
||||
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.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;
|
||||
if let Some(l) = state.config.find(&name) {
|
||||
let l = l.clone();
|
||||
match launcher::kill(&l) {
|
||||
Ok(()) => {
|
||||
state.running.insert(name, false);
|
||||
}
|
||||
Err(e) => {
|
||||
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<String> = 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()
|
||||
}
|
||||
// ── 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.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::SaveSettings => {
|
||||
state.last_error = None;
|
||||
let version = state.settings_proton_version.trim().to_string();
|
||||
let compat = PathBuf::from(state.settings_compat_dir.trim());
|
||||
match state.config.set_globals(Some(version), Some(compat)) {
|
||||
Ok(()) => {
|
||||
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 service…".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 service…".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() {
|
||||
"Service installed — autostarts on login.".into()
|
||||
} else {
|
||||
"Service 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 {
|
||||
std::env::var("HOME")
|
||||
.ok()
|
||||
.map(|h| {
|
||||
PathBuf::from(h)
|
||||
.join(".config/systemd/user/umutray.service")
|
||||
.exists()
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
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),
|
||||
])
|
||||
}
|
||||
|
||||
// ── Top-level view ─────────────────────────────────────────────────────────
|
||||
|
||||
fn view(state: &Dashboard) -> Element<'_, Message> {
|
||||
if state.settings_open {
|
||||
return view_settings(state);
|
||||
}
|
||||
|
||||
let settings_btn = button(text("⚙").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<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.adding_game.get(&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<Message> = 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)>,
|
||||
) -> Element<'a, Message> {
|
||||
let status_label = if running {
|
||||
"● Running"
|
||||
} else if installed {
|
||||
"○ Installed"
|
||||
} else {
|
||||
"· Not installed"
|
||||
};
|
||||
|
||||
let action: Element<Message> = {
|
||||
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 header = row![
|
||||
text(&l.display).size(15),
|
||||
text(" — ").size(12),
|
||||
text(status_label).size(12),
|
||||
iced::widget::horizontal_space(),
|
||||
action,
|
||||
]
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let mut rows: Vec<Element<Message>> = vec![header.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 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 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, 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<Message> = match checks {
|
||||
None => text("Running checks…").size(12).into(),
|
||||
Some(results) => {
|
||||
let rows: Vec<Element<Message>> = 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![
|
||||
text("⚙ Settings").size(24),
|
||||
iced::widget::horizontal_space(),
|
||||
button(text("← Back").size(13))
|
||||
.on_press(Message::HideSettings)
|
||||
.style(button::secondary),
|
||||
]
|
||||
.align_y(Alignment::Center);
|
||||
|
||||
let proton_version_input =
|
||||
text_input("e.g. GE-Proton or GE-Proton10-34", &state.settings_proton_version)
|
||||
.on_input(Message::SettingsProtonVersionChanged)
|
||||
.padding(8);
|
||||
|
||||
let compat_dir_input = text_input(
|
||||
"e.g. ~/.local/share/Steam/compatibilitytools.d",
|
||||
&state.settings_compat_dir,
|
||||
)
|
||||
.on_input(Message::SettingsCompatDirChanged)
|
||||
.padding(8);
|
||||
|
||||
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_input,
|
||||
text("GE-Proton compat directory").size(13),
|
||||
compat_dir_input,
|
||||
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)
|
||||
.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}"))
|
||||
}
|
||||
Reference in New Issue
Block a user