Files
umutray/src/gui.rs
T
funman300 c4587b0729 fix: downgrade iced_fonts to 0.1 for iced 0.13 compatibility
iced_fonts 0.3 pulls in iced_widget 0.14 / iced_renderer 0.14 which
breaks release builds when used alongside iced 0.13. Pin to 0.1.x
which targets iced 0.12/0.13 and drop the unused iced_aw dependency.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-18 23:12:52 -07:00

1178 lines
41 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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<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>),
}
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)>,
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,
}
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<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.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<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()
}
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<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("\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<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),
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<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)>,
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<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 version_badge: Element<Message> = 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<Element<Message>> = 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<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![
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}"))
}