diff --git a/.gitignore b/.gitignore index ea8c4bf..95d8e6f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,8 @@ /target +.vscode/ + +# Packaging build artifacts +packaging/pkg/ +packaging/src/ +packaging/umutray/ +packaging/*.pkg.tar.zst diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 082b194..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "makefile.configureOnOpen": false -} \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md deleted file mode 100644 index bd2fcb6..0000000 --- a/CLAUDE.md +++ /dev/null @@ -1,22 +0,0 @@ -## umutray refactor tasks - -Work through these issues identified in code review. Address them one at a time and confirm before moving on. - -### Packaging -- [ ] Remove the Makefile and replace with a proper Arch PKGBUILD following https://wiki.archlinux.org/title/Rust_package_guidelines -- [ ] Create a separate repo for the PKGBUILD (keeps packaging out of source repo and makes it AUR-uploadable). Reference the local repo path in the PKGBUILD so it always builds the latest version without pushing/pulling. - -### Systemd / tray architecture -- [ ] Remove runtime systemd unit file generation from Rust code — unit files should be static files shipped in the AUR package, not generated at runtime by the app. -- [ ] Reconsider the `service install` command — the tray icon should use the StatusNotifierItem/AppIndicator XDG protocol (https://www.freedesktop.org/wiki/Specifications/StatusNotifierItem) rather than a systemd service. - -### Code quality -- [ ] Add `#![forbid(unsafe_code)]` to the top of main.rs to enforce safe Rust project-wide. -- [ ] Replace manual terminal color escape codes (in main.rs and detect.rs) with a crate like `colored` or `owo-colors`. -- [ ] Replace manual home directory path construction in config.rs (~L88) with the `dirs` crate. - -### UX / GUI -- [ ] Fix blocking UI on long-running button actions (launch, kill, download) — use iced Command/async tasks so the UI keeps rendering and shows a loading state. - -### Misc -- [ ] Audit and document or refactor the unclear code at main.rs:307. \ No newline at end of file diff --git a/README.md b/README.md index 7fda029..15a7078 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ Launch / Kill entries. Users can add or remove launchers in `config edit`. (no ~600 MB in-memory buffering), with a progress indicator. - `diagnose` — sanity-checks umu-run, Vulkan, display server, per-launcher prefix / exe / ownership / running state. -- `service` — installs a `systemd --user` unit so the tray autostarts with +- `service` — installs an XDG autostart entry so the tray autostarts with the graphical session. - `setup` — graphical wizard (iced) that downloads an installer URL (with progress bar) or accepts a local `.exe`, then runs it via @@ -59,6 +59,7 @@ umutray service install | Command | What it does | | -------------------------------- | ------------------------------------------------------- | | `umutray` | Start the tray daemon (default) | +| `umutray gui` | Open the graphical dashboard (with tray icon) | | `umutray launchers` | List configured launchers and their state | | `umutray launch ` | Launch a specific launcher (e.g. `umutray launch epic`) | | `umutray kill []` | Kill one launcher, or all if no name is given | @@ -78,9 +79,9 @@ umutray service install | `umutray config add-game …` | Attach a game to a launcher (needs `--exe-path`) | | `umutray config remove-game …` | Drop a game from a launcher | | `umutray config set-game-flags …`| Per-game overlay toggles: gamemode/mangohud/gamescope | -| `umutray service install` | Write + enable a `systemd --user` unit | -| `umutray service uninstall` | Stop, disable, and remove the unit | -| `umutray service status` | `systemctl --user status umutray.service` | +| `umutray service install` | Write XDG autostart entry (tray starts on login) | +| `umutray service uninstall` | Remove the autostart and desktop entries | +| `umutray service status` | Show whether XDG autostart is enabled | ## Config diff --git a/TODO.md b/TODO.md deleted file mode 100644 index 87437d6..0000000 --- a/TODO.md +++ /dev/null @@ -1,8 +0,0 @@ -# Project Tasks - -- [ ] automatically detect all wine and proton versions installed and have a drop down selection menu globally and for each launcher entry -- [ ] Change the settings button to a cog wheel icon -- [ ] Overhaul the settings menu -- [ ] Overhaul the main dashboard -- [ ] Prefix Dependancy Manager -- { } A speical option for world of warcraft game installs to let you install and launcher the curse forge mod manager within the world of warcraft prefix. Following the trent of modularity and a simplistic approach \ No newline at end of file diff --git a/packaging/PKGBUILD b/packaging/PKGBUILD index a2ca8c1..c0784a5 100644 --- a/packaging/PKGBUILD +++ b/packaging/PKGBUILD @@ -48,13 +48,11 @@ build() { package() { cd "$pkgname" - # Binary — install to ~/.local/bin for user-local usage - install -Dm755 target/release/umutray "${HOME}/.local/bin/umutray" + # Binary + install -Dm755 target/release/umutray "$pkgdir/usr/bin/umutray" - # App menu entry for the current user - install -Dm644 umutray.desktop "${HOME}/.local/share/applications/umutray.desktop" - sed -i "s|Exec=umutray|Exec=${HOME}/.local/bin/umutray|" \ - "${HOME}/.local/share/applications/umutray.desktop" + # App menu entry + install -Dm644 umutray.desktop "$pkgdir/usr/share/applications/umutray.desktop" # License install -Dm644 LICENSE "$pkgdir/usr/share/licenses/$pkgname/LICENSE" diff --git a/src/config.rs b/src/config.rs index ebd82e6..52300b7 100644 --- a/src/config.rs +++ b/src/config.rs @@ -126,7 +126,7 @@ pub fn presets() -> Vec { "Program Files (x86)/Battle.net/Battle.net Launcher.exe", ), gameid: "umu-battlenet".into(), - process_pattern: r"Battle\.net".into(), + process_pattern: r"Battle\.net|Blizzard.*Agent".into(), installer_url: Some( "https://www.battle.net/download/getInstallerForGame?os=win&gameProgram=BATTLENET_APP&version=Live".into(), ), diff --git a/src/detect.rs b/src/detect.rs index 100c4da..2658219 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -191,16 +191,86 @@ fn scan_exe_dir( if !seen.insert(rel_lower) { continue; } - let display = path - .file_stem() - .and_then(|s| s.to_str()) - .unwrap_or("Unknown") - .to_string(); + let display = prettify_game_name(&path); out.push((display, rel_str)); } } } +/// Derive a human-readable game name from an exe path. +/// +/// Strategy: use the parent directory name (e.g. "Call of Duty" from +/// `Program Files/Call of Duty/game.exe`) unless it looks generic +/// (like "Bin", "x64", "Binaries"), in which case walk up. Falls back +/// to humanising the exe file stem by inserting spaces before capitals. +fn prettify_game_name(path: &Path) -> String { + // Generic directory names that don't make good game labels + const GENERIC_DIRS: &[&str] = &[ + "bin", "binaries", "x64", "x86", "win64", "win32", "retail", + "shipping", "game", "runtime", "_retail_", "_commonredist", + "launcher", "engine", "client", + ]; + + // Try parent directories (closest first, up to 3 levels) + let mut dir = path.parent(); + for _ in 0..3 { + let Some(d) = dir else { break }; + let name = d.file_name().and_then(|n| n.to_str()).unwrap_or(""); + let lower = name.to_lowercase(); + if !name.is_empty() + && !GENERIC_DIRS.iter().any(|g| lower == *g) + && !lower.starts_with("program files") + { + return name.to_string(); + } + dir = d.parent(); + } + + // Fallback: humanise the exe stem ("BlackOps6" → "Black Ops 6") + let stem = path + .file_stem() + .and_then(|s| s.to_str()) + .unwrap_or("Unknown"); + humanise_stem(stem) +} + +/// Insert spaces before uppercase runs and digit boundaries. +/// "BlackOps6" → "Black Ops 6", "HITMAN3" → "HITMAN 3" +fn humanise_stem(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 4); + let chars: Vec = s.chars().collect(); + for (i, &c) in chars.iter().enumerate() { + if i > 0 { + let prev = chars[i - 1]; + // Letter→digit or digit→letter boundary + if (prev.is_alphabetic() && c.is_ascii_digit()) + || (prev.is_ascii_digit() && c.is_alphabetic()) + { + out.push(' '); + } + // lowercase→uppercase ("nO" in "BlackOps") + else if prev.is_lowercase() && c.is_uppercase() { + out.push(' '); + } + // UPPER run ending: "ABCdef" → "AB Cdef" + else if i + 1 < chars.len() + && prev.is_uppercase() + && c.is_uppercase() + && chars[i + 1].is_lowercase() + { + out.push(' '); + } + } + // Replace underscores / hyphens with spaces + if c == '_' || c == '-' { + out.push(' '); + } else { + out.push(c); + } + } + out +} + const MAX_DEPTH: u32 = 3; pub fn run(config: &Config, extra_dirs: &[PathBuf], apply: bool) -> Result<()> { diff --git a/src/gui.rs b/src/gui.rs index fe6fb8f..592f55c 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -1,24 +1,43 @@ -use crate::{config::Config, detect, diagnose, launcher, proton, service, util::{async_blocking, pick_file, pick_folder}}; +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, Column, + button, column, container, mouse_area, pick_list, row, scrollable, text, text_input, Space, + Column, }; use iced::{ Alignment, Background, Border, Color, Element, Length, Padding, Subscription, Task, Theme, }; use std::collections::HashMap; use std::path::PathBuf; +use std::sync::{Arc, Mutex}; use std::time::Duration; +/// What the user chose when closing the GUI window. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CloseAction { + Quit, + MinimizeToTray, +} + #[derive(Debug, Clone)] pub enum Message { FontLoaded, PollProcesses, + PollDone(HashMap), ReloadConfig, AddLauncher, Launch(String), + LaunchDone(String, Result<(), String>), Kill(String), Play(String, String), + PlayDone(String, String, Result<(), String>), Setup(String), ToggleGameMode(String, String), ToggleMangoHud(String, String), @@ -62,6 +81,11 @@ pub enum Message { ServiceUninstall, ServiceActionDone(Result<(), String>), LaunchProtontricks, + // Close dialog + CloseRequested(iced::window::Id), + ConfirmQuit, + ConfirmMinimize, + CancelClose, } struct Dashboard { @@ -71,6 +95,8 @@ struct Dashboard { proton_status: String, last_error: Option, context_menu: Option, + /// Launchers currently being launched (show spinner instead of button). + launch_busy: std::collections::HashSet, // Detect detect_busy: bool, detect_result: String, @@ -88,10 +114,13 @@ struct Dashboard { proton_versions: Vec, service_busy: bool, service_status: String, + // Close dialog + close_dialog_open: bool, + close_action: Arc>>, } impl Dashboard { - fn new(config: Config) -> Self { + fn new(config: Config, close_action: Arc>>) -> Self { let mut running = HashMap::new(); for l in &config.launchers { running.insert(l.name.clone(), launcher::is_running(l)); @@ -106,6 +135,7 @@ impl Dashboard { 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, @@ -119,6 +149,8 @@ impl Dashboard { proton_versions, service_busy: false, service_status: String::new(), + close_dialog_open: false, + close_action, } } } @@ -127,9 +159,31 @@ fn update(state: &mut Dashboard, msg: Message) -> Task { match msg { Message::FontLoaded => Task::none(), Message::PollProcesses => { - for l in &state.config.launchers { - state.running.insert(l.name.clone(), launcher::is_running(l)); - } + 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() + .map(|s| s.success()) + .unwrap_or(false); + 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(false)); Task::none() } Message::ReloadConfig => { @@ -150,17 +204,28 @@ fn update(state: &mut Dashboard, msg: Message) -> Task { } Message::Launch(name) => { state.last_error = None; - state.running.insert(name.clone(), true); + 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(); - std::thread::spawn(move || { - if let Err(e) = launcher::launch(&config, &l) { - eprintln!("umutray: launch {name} failed: {e}"); + 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) => { @@ -190,15 +255,24 @@ fn update(state: &mut Dashboard, msg: Message) -> Task { 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}"); - } - }); + 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) @@ -310,7 +384,7 @@ fn update(state: &mut Dashboard, msg: Message) -> Task { if h.configured { format!("{} ✓", h.display) } else { - format!("{} (not in config — use Setup to add)", h.display) + format!("{} @ {}", h.display, h.prefix.display()) } }) .collect(); @@ -350,10 +424,10 @@ fn update(state: &mut Dashboard, msg: Message) -> Task { } // ── 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())); + 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() } @@ -514,15 +588,14 @@ fn update(state: &mut Dashboard, msg: Message) -> Task { 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+") + // 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() }; - let valid_version = version_key == "GE-Proton" - || version_key.starts_with("GE-Proton"); - if !valid_version { + 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(); @@ -579,6 +652,29 @@ fn update(state: &mut Dashboard, msg: Message) -> Task { } 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() + } } } @@ -612,50 +708,94 @@ fn subscription(_: &Dashboard) -> Subscription { Subscription::batch([ iced::time::every(Duration::from_secs(2)).map(|_| Message::PollProcesses), iced::time::every(Duration::from_secs(5)).map(|_| Message::ReloadConfig), + iced::window::close_requests().map(Message::CloseRequested), ]) } +// Palette constants, icon(), section_heading(), btn_accent(), btn_ghost(), +// btn_danger() are imported from crate::theme. + // ── Top-level view ───────────────────────────────────────────────────────── fn view(state: &Dashboard) -> Element<'_, Message> { + if state.close_dialog_open { + return view_close_dialog(); + } if state.settings_open { return view_settings(state); } - let settings_btn = button(text("\u{f3e2}").font(iced::Font::with_name("bootstrap-icons")).size(16)) + // ── Title bar ───────────────────────────────────────────────────────── + let settings_btn = button(icon("\u{f3e2}", 16)) .on_press(Message::ShowSettings) - .style(button::secondary); + .style(btn_ghost) + .padding([6, 10]); let title = container( row![ - text("umutray").size(28), + 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: 16.0, right: 20.0, bottom: 8.0, left: 20.0 }); + .padding(Padding { top: 20.0, right: 24.0, bottom: 6.0, left: 24.0 }); - let add_btn = button(text("+ Add Launcher").size(13)) - .on_press(Message::AddLauncher) - .style(button::secondary); + // ── 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![text("No launchers configured.").size(15), add_btn,] - .spacing(12) - .padding([20, 20]), + 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), + .width(Length::Fill) + .center_x(Length::Fill), ]; return container(body) .width(Length::Fill) .height(Length::Fill) + .style(surface_bg) .into(); } + // ── Launcher cards ──────────────────────────────────────────────────── let mut cards: Vec> = Vec::new(); for l in &state.config.launchers { @@ -678,6 +818,7 @@ fn view(state: &Dashboard) -> Element<'_, Message> { 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), @@ -686,19 +827,8 @@ fn view(state: &Dashboard) -> Element<'_, Message> { 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() - } - }) + .padding([16, 20]) + .style(card_style) .width(Length::Fill), ) .on_right_press(Message::ShowContextMenu(l.name.clone())); @@ -706,73 +836,103 @@ fn view(state: &Dashboard) -> Element<'_, Message> { cards.push(card.into()); } + // ── Footer ──────────────────────────────────────────────────────────── let proton_btn = button( - text(if state.proton_busy { "Updating…" } else { "Update GE-Proton" }).size(13), + 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(button::secondary); + .style(btn_ghost) + .padding([5, 12]); let detect_btn = button( - text(if state.detect_busy { "Scanning…" } else { "Detect Installed" }).size(13), + 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(button::secondary); + .style(btn_ghost) + .padding([5, 12]); + + let footer_status: Element = { + let txt = if !state.proton_status.is_empty() { + state.proton_status.clone() + } else if !state.detect_result.is_empty() { + state.detect_result.clone() + } else { + String::new() + }; + text(txt).size(10).style(|_: &Theme| text::Style { color: Some(DIM) }).into() + }; let footer = container( - 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), + row![ + detect_btn, + proton_btn, + add_btn, + iced::widget::horizontal_space(), + footer_status, ] + .align_y(Alignment::Center) .spacing(6), ) - .padding([10, 20]); + .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) - .push(container(add_btn).padding(Padding { - top: 8.0, - right: 0.0, - bottom: 0.0, - left: 0.0, - })) - .spacing(8) + .spacing(10) .padding(Padding { - top: 0.0, - right: 20.0, - bottom: 8.0, - left: 20.0, + top: 12.0, + right: 24.0, + bottom: 12.0, + left: 24.0, }) .width(Length::Fill); let error_bar: Element = if let Some(err) = &state.last_error { container( - text(format!("⚠ {err}")) - .size(12) - .style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(1.0, 0.45, 0.45)), - }), + 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([4, 20]) + .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 { - text("").into() + Space::new(0, 0).into() }; let body = column![ title, + accent_bar, scrollable(cards_col).height(Length::Fill), error_bar, - iced::widget::horizontal_rule(1), footer, ]; container(body) .width(Length::Fill) .height(Length::Fill) + .style(surface_bg) .into() } @@ -781,23 +941,9 @@ fn view(state: &Dashboard) -> Element<'_, Message> { /// Styled inner container for sub-sections inside a card. fn sub_card<'a>(content: impl Into>) -> Element<'a, Message> { container(content) - .padding(Padding::from([8, 12])) + .padding(Padding::from([10, 14])) .width(Length::Fill) - .style(|theme: &Theme| { - let p = theme.extended_palette(); - container::Style { - background: Some(Background::Color(Color { - a: 0.35, - ..p.background.strong.color - })), - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 6.0.into(), - }, - ..Default::default() - } - }) + .style(sub_card_style) .into() } @@ -805,54 +951,53 @@ 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> { - // ── Colours ──────────────────────────────────────────────────────────── - let accent = Color::from_rgb(0.55, 0.75, 1.0); - let green = Color::from_rgb(0.35, 0.85, 0.45); - let muted = Color::from_rgb(0.45, 0.45, 0.45); - let dim = Color::from_rgb(0.38, 0.38, 0.38); - let subtle = Color::from_rgb(0.5, 0.5, 0.5); - - let status_color = if running { green } else if installed { accent } else { muted }; - let status_icon = if running { "\u{f287}" } else if installed { "\u{f26a}" } else { "\u{f28a}" }; // circle-fill, check-circle, x-circle - let status_str = if running { "Running" } else if installed { "Ready" } else { "Not installed" }; + let status_color = if running { GREEN } else if launching || installed { ACCENT } else { MUTED }; + let status_icon = if running { "\u{f287}" } else if launching { "\u{f130}" } else if installed { "\u{f26a}" } else { "\u{f28a}" }; + let status_str = if running { "Running" } else if launching { "Launching…" } else if installed { "Ready" } else { "Not installed" }; // ── Action button ───────────────────────────────────────────────────── let action: Element = { let n = l.name.clone(); if !installed { button( - row![ - text("\u{f3e2}").font(iced::Font::with_name("bootstrap-icons")).size(12), - text(" Setup").size(13), - ].align_y(Alignment::Center).spacing(4), + row![icon("\u{f3e2}", 13), text(" Setup").size(12)] + .align_y(Alignment::Center).spacing(4), ) - .on_press(Message::Setup(n)) - .style(button::secondary) - .into() + .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![ - text("\u{f5de}").font(iced::Font::with_name("bootstrap-icons")).size(12), - text(" Stop").size(13), - ].align_y(Alignment::Center).spacing(4), + row![icon("\u{f5de}", 13), text(" Stop").size(12)] + .align_y(Alignment::Center).spacing(4), ) - .on_press(Message::Kill(n)) - .style(button::danger) - .into() + .on_press(Message::Kill(n)) + .style(btn_danger) + .padding([7, 14]) + .into() } else { button( - row![ - text("\u{f4f4}").font(iced::Font::with_name("bootstrap-icons")).size(12), - text(" Launch").size(13), - ].align_y(Alignment::Center).spacing(4), + row![icon("\u{f4f4}", 13), text(" Launch").size(12)] + .align_y(Alignment::Center).spacing(4), ) - .on_press(Message::Launch(n)) - .style(button::primary) - .into() + .on_press(Message::Launch(n)) + .style(btn_accent) + .padding([7, 14]) + .into() } }; @@ -860,34 +1005,46 @@ fn launcher_card<'a>( let version_badge: Element = if let Some(v) = &l.proton_version { container( text(v).size(10).style(move |_: &Theme| text::Style { - color: Some(accent), + color: Some(ACCENT), }) ) - .padding(Padding::from([2, 6])) - .style(move |_: &Theme| container::Style { - background: Some(Background::Color(Color { r: 0.55, g: 0.75, b: 1.0, a: 0.12 })), - border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 3.0.into() }, + .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 { - text("").into() + 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(17), + text(&l.display).size(18), version_badge, ].align_y(Alignment::Center).spacing(8), row![ - text(status_icon).font(iced::Font::with_name("bootstrap-icons")).size(10) - .style(move |_: &Theme| text::Style { color: Some(status_color) }), - text(format!(" {status_str}")).size(11) - .style(move |_: &Theme| text::Style { color: Some(status_color) }), + status_pill, ].align_y(Alignment::Center), - ].spacing(4), + ].spacing(6), iced::widget::horizontal_space(), action, ] @@ -900,27 +1057,24 @@ fn launcher_card<'a>( let has_scan = scan_results.map(|r| !r.is_empty()).unwrap_or(false); if has_games || has_scan || scan_busy { - // Section header let game_count = l.games.len(); let section_label: String = if !has_games { - "Detected".to_string() + "DETECTED".to_string() } else if game_count == 1 { - "1 Game".to_string() + "1 GAME".to_string() } else { - format!("{game_count} Games") + format!("{game_count} GAMES") }; let lname_scan = l.name.clone(); - let rescan_btn = button( - text("\u{f130}").font(iced::Font::with_name("bootstrap-icons")).size(11) - ) + let rescan_btn = button(icon("\u{f130}", 11)) .on_press_maybe((!scan_busy).then_some(Message::ScanGames(lname_scan))) - .style(button::secondary) - .padding([3, 6]); + .style(btn_ghost) + .padding([4, 7]); let section_header = row![ - text(section_label).size(11) - .style(move |_: &Theme| text::Style { color: Some(subtle) }), + text(section_label).size(10) + .style(move |_: &Theme| text::Style { color: Some(DIM) }), iced::widget::horizontal_space(), rescan_btn, ] @@ -933,13 +1087,13 @@ fn launcher_card<'a>( game_items.push( container( row![ - text("\u{f130}").font(iced::Font::with_name("bootstrap-icons")).size(12) - .style(move |_: &Theme| text::Style { color: Some(accent) }), + 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) }), + .style(move |_: &Theme| text::Style { color: Some(ACCENT) }), ].align_y(Alignment::Center), ) - .padding(Padding::from([6, 0])) + .padding(Padding::from([8, 0])) .into(), ); } @@ -951,36 +1105,34 @@ fn launcher_card<'a>( let d = display.clone(); let e = exe.clone(); let add_btn = button( - row![ - text("\u{f64d}").font(iced::Font::with_name("bootstrap-icons")).size(11), - text(" Add").size(11), - ].align_y(Alignment::Center).spacing(3), + row![icon("\u{f64d}", 11), text(" Add").size(11)] + .align_y(Alignment::Center).spacing(3), ) - .on_press(Message::AddScannedGame(lname, d, e)) - .style(button::primary) - .padding([4, 8]); + .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) }), + text("◇").size(10).style(move |_: &Theme| text::Style { color: Some(DIM) }), column![ - text(display).size(12), - text(exe).size(9).style(move |_: &Theme| text::Style { color: Some(dim) }), - ].spacing(1), + 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(8), + .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) }) + .style(move |_: &Theme| text::Style { color: Some(MUTED) }) ) - .padding(Padding::from([4, 0])) + .padding(Padding::from([6, 0])) .into(), ); } @@ -993,15 +1145,13 @@ fn launcher_card<'a>( let game_installed = g.full_exe_path(l).exists(); // Play button - let play = button( - text("\u{f4f4}").font(iced::Font::with_name("bootstrap-icons")).size(13) - ) + let play = button(icon("\u{f4f4}", 14)) .on_press_maybe(game_installed.then_some(Message::Play(lname.clone(), gname.clone()))) - .style(button::primary) - .padding([6, 10]); + .style(btn_accent) + .padding([7, 11]); // Status dot - let status_dot_color = if game_installed { green } else { muted }; + 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), }); @@ -1012,23 +1162,27 @@ fn launcher_card<'a>( let gs_active = g.gamescope.is_some(); let pill = |label: &'static str, active: bool, msg: Message| -> Element<'a, Message> { - let (fg, bg_alpha) = if active { - (accent, 0.18) + let (fg, bg_alpha, border_alpha) = if active { + (ACCENT, 0.15, 0.30) } else { - (muted, 0.0) + (MUTED, 0.0, 0.10) }; button( text(label).size(9).style(move |_: &Theme| text::Style { color: Some(fg) }) ) - .on_press(msg) - .style(move |theme: &Theme, status| { - let mut s = (button::secondary)(theme, status); - s.background = Some(Background::Color(Color { r: 0.55, g: 0.75, b: 1.0, a: bg_alpha })); - s.border.radius = 10.0.into(); - s - }) - .padding([2, 8]) - .into() + .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![ @@ -1038,55 +1192,51 @@ fn launcher_card<'a>( ].spacing(4); // Remove - let remove_game = button( - text("\u{f659}").font(iced::Font::with_name("bootstrap-icons")).size(11) - ) + let remove_game = button(icon("\u{f659}", 11)) .on_press(Message::RemoveGame(lname, gname)) - .style(button::danger) - .padding([3, 6]); + .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(13), + 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(6), + .spacing(8), )); } sections.push( - Column::with_children(game_items).spacing(4).into(), + Column::with_children(game_items).spacing(6).into(), ); } else { - // Empty state — no games, no scan results + // Empty state let lname = l.name.clone(); let lname2 = l.name.clone(); let scan_btn = button( - row![ - text("\u{f130}").font(iced::Font::with_name("bootstrap-icons")).size(11), - text(" Scan for games").size(11), - ].align_y(Alignment::Center).spacing(4), + row![icon("\u{f130}", 11), text(" Scan for games").size(11)] + .align_y(Alignment::Center).spacing(4), ) - .on_press(Message::ScanGames(lname)) - .style(button::secondary); + .on_press(Message::ScanGames(lname)) + .style(btn_ghost) + .padding([5, 12]); let browse_btn = button( - row![ - text("\u{f3e8}").font(iced::Font::with_name("bootstrap-icons")).size(11), - text(" Browse…").size(11), - ].align_y(Alignment::Center).spacing(4), + row![icon("\u{f3e8}", 11), text(" Browse…").size(11)] + .align_y(Alignment::Center).spacing(4), ) - .on_press(Message::BrowseGameExe(lname2)) - .style(button::secondary); + .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) }), + .style(move |_: &Theme| text::Style { color: Some(MUTED) }), iced::widget::horizontal_space(), scan_btn, browse_btn, @@ -1105,32 +1255,33 @@ fn launcher_card<'a>( let lname5 = l.name.clone(); let name_input = text_input("Game name", name_val) .on_input(move |v| Message::AddGameNameChanged(lname.clone(), v)) - .padding(7) + .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(7) + .padding(8) .size(12) .width(Length::FillPortion(3)); let browse_btn = button(text("Browse…").size(11)) .on_press(Message::BrowseGameExe(lname5)) - .style(button::secondary); + .style(btn_ghost) + .padding([6, 10]); let can_confirm = !name_val.trim().is_empty() && !exe_val.trim().is_empty(); let confirm_btn = button( - row![ - text("\u{f64d}").font(iced::Font::with_name("bootstrap-icons")).size(11), - text(" Add Game").size(11), - ].align_y(Alignment::Center).spacing(3), + 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(button::primary); + .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(button::secondary); + .style(btn_ghost) + .padding([6, 10]); sections.push(sub_card( column![ - text("Add Game").size(11).style(move |_: &Theme| text::Style { color: Some(subtle) }), + section_heading("Add Game"), row![name_input, exe_input, browse_btn] .align_y(Alignment::Center) .spacing(6), @@ -1140,7 +1291,7 @@ fn launcher_card<'a>( .width(Length::Fill) .align_x(Alignment::End), ] - .spacing(6), + .spacing(8), )); } @@ -1152,22 +1303,18 @@ fn launcher_card<'a>( 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(button::secondary) - .padding([3, 8]); + .style(btn_ghost) + .padding([4, 9]); - let scan_btn = button( - text("\u{f130}").font(iced::Font::with_name("bootstrap-icons")).size(10) - ) + let scan_btn = button(icon("\u{f130}", 10)) .on_press_maybe((!scan_busy).then_some(Message::ScanGames(lname_scan))) - .style(button::secondary) - .padding([3, 6]); + .style(btn_ghost) + .padding([4, 7]); - let browse_btn = button( - text("\u{f3e8}").font(iced::Font::with_name("bootstrap-icons")).size(10) - ) + let browse_btn = button(icon("\u{f3e8}", 10)) .on_press(Message::BrowseGameExe(lname_browse)) - .style(button::secondary) - .padding([3, 6]); + .style(btn_ghost) + .padding([4, 7]); sections.push( row![ @@ -1181,54 +1328,61 @@ fn launcher_card<'a>( .into(), ); - Column::with_children(sections).spacing(10).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(15), - text("Actions").size(11).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.45, 0.45, 0.45)), + text(&l.display).size(16), + text("ACTIONS").size(10).style(|_: &Theme| text::Style { + color: Some(DIM), }), ] - .spacing(2), + .spacing(3), iced::widget::horizontal_space(), - button(text("✕").size(12)) + button(icon("\u{f659}", 13)) .on_press(Message::HideContextMenu) - .style(button::secondary), + .style(btn_ghost) + .padding([5, 8]), ] .align_y(Alignment::Center); - let open_prefix = button(text("Open install folder").size(13)) - .on_press(Message::OpenPrefix(l.name.clone())) - .style(button::secondary) - .width(Length::Fill); + 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 rerun_setup = button(text("Re-run setup wizard").size(13)) - .on_press(Message::RerunSetup(l.name.clone())) - .style(button::secondary) - .width(Length::Fill); - - let diagnose = button(text("Run diagnostics").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); + 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, - iced::widget::horizontal_rule(1), - open_prefix, - rerun_setup, - diagnose, + 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(5) + .spacing(2) .into() } @@ -1238,65 +1392,184 @@ fn diagnose_card<'a>( ) -> Element<'a, Message> { let header = row![ column![ - text(format!("Diagnostics")).size(15), + text("Diagnostics").size(16), text(&l.display).size(11).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.55, 0.75, 1.0)), + color: Some(ACCENT), }), ] - .spacing(2), + .spacing(3), iced::widget::horizontal_space(), - button(text("✕").size(12)) + button(icon("\u{f659}", 13)) .on_press(Message::HideDiagnose) - .style(button::secondary), + .style(btn_ghost) + .padding([5, 8]), ] .align_y(Alignment::Center); let body: Element = match checks { - None => text("Running checks…").size(12).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.55, 0.75, 1.0)), - }).into(), + None => container( + row![ + icon("\u{f130}", 13).style(|_: &Theme| text::Style { color: Some(ACCENT) }), + text(" Running checks…").size(12).style(|_: &Theme| text::Style { + color: Some(ACCENT), + }), + ].align_y(Alignment::Center), + ) + .padding([8, 0]) + .into(), Some(results) => { let rows: Vec> = results .iter() .map(|c| { let (sym, color) = if c.pass { - ("✓", Color::from_rgb(0.35, 0.85, 0.45)) + ("\u{f26a}", GREEN) // check-circle } else { - ("✗", Color::from_rgb(1.0, 0.45, 0.35)) + ("\u{f28a}", RED) // x-circle }; - 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() + 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(160.0)) + .height(Length::Fixed(180.0)) .into() } }; - column![header, iced::widget::horizontal_rule(1), body] - .spacing(8) - .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![text("\u{f3e2}").font(iced::Font::with_name("bootstrap-icons")).size(20), text(" Settings").size(24)].align_y(Alignment::Center), + 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(text("← Back").size(13)) - .on_press(Message::HideSettings) - .style(button::secondary), + 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" { @@ -1313,77 +1586,147 @@ fn view_settings(state: &Dashboard) -> Element<'_, Message> { &state.settings_compat_dir, ) .on_input(Message::SettingsCompatDirChanged) - .padding(8) + .padding(10) .width(Length::Fill); - let browse_compat_btn = button(text("Browse…").size(13)) + let browse_compat_btn = button(text("Browse…").size(12)) .on_press(Message::BrowseCompatDir) - .style(button::secondary); + .style(btn_ghost) + .padding([8, 14]); - let save_btn = button(text("Save").size(13)) - .on_press(Message::SaveSettings) - .style(button::primary); + 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( - text(if state.service_busy { "Working…" } else { "Install autostart service" }).size(13), + 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(button::secondary); + .style(btn_accent) + .padding([7, 14]); let svc_uninstall_btn = button( - text(if state.service_busy { "Working…" } else { "Remove autostart service" }).size(13), + 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(button::danger); + .style(btn_danger) + .padding([7, 14]); - let svc_status_label = if installed { - "Autostart: enabled (tray starts on login)" + let autostart_section = settings_section("Autostart", + column![ + row![ + icon(svc_status_icon, 13).style(move |_: &Theme| text::Style { color: Some(svc_status_color) }), + text(format!(" {svc_status_text}")).size(12).style(move |_: &Theme| text::Style { + color: Some(svc_status_color), + }), + iced::widget::horizontal_space(), + svc_install_btn, + svc_uninstall_btn, + ].align_y(Alignment::Center).spacing(8), + text(&state.service_status).size(11).style(|_: &Theme| text::Style { + color: Some(DIM), + }), + ].spacing(6).into(), + ); + + // ── Error bar ───────────────────────────────────────────────────────── + let error_el: Element = if let Some(err) = &state.last_error { + container( + row![ + icon("\u{f33b}", 13) + .style(|_: &Theme| text::Style { color: Some(RED) }), + text(format!(" {err}")).size(12) + .style(|_: &Theme| text::Style { color: Some(RED) }), + ].align_y(Alignment::Center), + ) + .padding([8, 18]) + .width(Length::Fill) + .style(|_: &Theme| container::Style { + background: Some(Background::Color(Color { a: 0.08, ..RED })), + border: Border { color: Color::TRANSPARENT, width: 0.0, radius: 8.0.into() }, + ..Default::default() + }) + .into() } else { - "Autostart: disabled" + Space::new(0, 0).into() }; 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("Tools").size(13), - button(text("Launch Protontricks").size(13)) - .on_press(Message::LaunchProtontricks) - .style(button::secondary), - 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), + Space::new(0, 6), + proton_section, + tools_section, + autostart_section, + error_el, ] - .spacing(10) - .padding(20); + .spacing(12) + .padding(24); - container(body) + container(scrollable(body)) .width(Length::Fill) .height(Length::Fill) + .style(surface_bg) .into() } -pub fn run(config: &Config) -> Result<()> { +pub fn run(config: &Config) -> Result { let config = config.clone(); + let close_action: Arc>> = Arc::new(Mutex::new(None)); + let ca = close_action.clone(); iced::application(|_: &Dashboard| String::from("umutray"), update, view) .subscription(subscription) .theme(|_| Theme::Dark) - .window(iced::window::Settings { - size: iced::Size::new(600.0, 560.0), + 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), load_font) - }) - .map_err(|e| anyhow::anyhow!("iced: {e}")) + 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) } diff --git a/src/launcher.rs b/src/launcher.rs index 71fb2cb..c0461b4 100644 --- a/src/launcher.rs +++ b/src/launcher.rs @@ -6,6 +6,20 @@ use std::process::Stdio; use std::thread; use std::time::Duration; +/// Resolve PROTONPATH for umu-run: the literal "GE-Proton" makes umu-run +/// auto-fetch the latest; a pinned version gets the full path in compat_dir. +pub fn resolve_proton_path(config: &Config, launcher: &Launcher) -> OsString { + let version = launcher + .proton_version + .as_deref() + .unwrap_or(&config.proton_version); + if version == "GE-Proton" { + version.to_string().into() + } else { + config.proton_compat_dir.join(version).into_os_string() + } +} + /// Spawn the launcher via umu-run and return immediately. pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> { let exe = launcher.full_exe_path(); @@ -18,17 +32,7 @@ pub fn launch(config: &Config, launcher: &Launcher) -> Result<()> { ); } - // PROTONPATH: umu-run accepts the literal "GE-Proton" to auto-fetch the - // latest; for any pinned version it expects a full path to the install dir. - let version = launcher - .proton_version - .as_deref() - .unwrap_or(&config.proton_version); - let proton_path: std::ffi::OsString = if version == "GE-Proton" { - version.to_string().into() - } else { - config.proton_compat_dir.join(version).into_os_string() - }; + let proton_path = resolve_proton_path(config, launcher); std::process::Command::new("umu-run") .env("WINEPREFIX", &launcher.prefix_dir) @@ -59,15 +63,7 @@ pub fn play_game(config: &Config, launcher: &Launcher, game: &Game) -> Result<() ); } - let version = launcher - .proton_version - .as_deref() - .unwrap_or(&config.proton_version); - let proton_path: OsString = if version == "GE-Proton" { - version.to_string().into() - } else { - config.proton_compat_dir.join(version).into_os_string() - }; + let proton_path = resolve_proton_path(config, launcher); let (prog, args) = build_wrapped_argv(&exe, game); diff --git a/src/main.rs b/src/main.rs index 447431e..df20ac3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -8,6 +8,7 @@ mod launcher; mod proton; mod service; mod setup; +mod theme; mod tray; mod util; @@ -322,10 +323,20 @@ fn main() -> Result<()> { }, Commands::Gui => { - gui::run(&config)?; - // After the GUI window closes, continue into the system tray. - let config = config::Config::load().unwrap_or(config); - tray::run(&config)?; + // Start the tray icon immediately alongside the GUI. + let tray_handle = tray::spawn(&config); + + match gui::run(&config)? { + gui::CloseAction::Quit => { + tray_handle.shutdown(); + } + gui::CloseAction::MinimizeToTray => { + // GUI closed, tray keeps running. Block until killed. + loop { + std::thread::sleep(std::time::Duration::from_secs(60)); + } + } + } } Commands::Detect { dir, apply } => { diff --git a/src/setup.rs b/src/setup.rs index 0a3704a..5191dae 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -1,10 +1,19 @@ -use crate::{config::{self, Config, Launcher}, util::{async_blocking, pick_folder}}; +use crate::{ + config::{self, Config, Launcher}, + theme::{ + btn_accent, btn_ghost, card_style, icon, sub_card_style, surface_bg, ACCENT, + DIM, GREEN, MUTED, RED, SURFACE_RAISED, + }, + util::{async_blocking, pick_folder}, +}; use anyhow::Result; use iced::widget::{ - button, column, container, pick_list, progress_bar, row, scrollable, text, text_input, Column, + button, column, container, pick_list, progress_bar, row, scrollable, text, text_input, Space, + Column, +}; +use iced::{ + Alignment, Background, Border, Color, Element, Length, Padding, Subscription, Task, Theme, }; -use iced::{Alignment, Background, Border, Color, Element, Length, Padding, Subscription, Task, Theme}; -use std::ffi::OsString; use std::io::{BufRead, BufReader, Read, Write}; use std::path::{Path, PathBuf}; use std::process::{Command, Stdio}; @@ -347,7 +356,7 @@ fn update(state: &mut State, message: Message) -> Task { .expect("launcher set before install"); let exe = launcher.full_exe_path(); // Save to config only on successful install - if matches!(&res, Ok(_)) && exe.exists() { + if res.is_ok() && exe.exists() { if state.config.find(&launcher.name).is_none() { state.config.launchers.push(launcher.clone()); let _ = state.config.save(); @@ -406,18 +415,7 @@ fn section_card<'a>(content: impl Into>) -> Element<'a, Mes container(content) .padding(Padding::from([12, 16])) .width(Length::Fill) - .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() - } - }) + .style(card_style) .into() } @@ -426,10 +424,12 @@ fn view_picking(state: &State) -> Element<'_, Message> { let header = container( column![ step_indicator(1), - text("Add a Launcher").size(26), + text("Add a Launcher").size(26).style(|_: &Theme| text::Style { + color: Some(ACCENT), + }), text("Choose a launcher and set its install location.").size(13) .style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.6, 0.6, 0.6)), + color: Some(DIM), }), ] .spacing(4), @@ -447,8 +447,8 @@ fn view_picking(state: &State) -> Element<'_, Message> { let launcher_card = section_card( column![ - text("Launcher").size(12).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.55, 0.75, 1.0)), + text("LAUNCHER").size(10).style(|_: &Theme| text::Style { + color: Some(DIM), }), picker, ] @@ -461,14 +461,18 @@ fn view_picking(state: &State) -> Element<'_, Message> { .padding(8) .width(Length::Fill); - let browse_btn = button(text("Browse…").size(13)) - .on_press(Message::BrowsePrefix) - .style(button::secondary); + let browse_btn = button( + row![icon("\u{f3e8}", 12), text(" Browse…").size(13)] + .align_y(Alignment::Center).spacing(4), + ) + .on_press(Message::BrowsePrefix) + .style(btn_ghost) + .padding([8, 14]); let location_card = section_card( column![ - text("Install Location").size(12).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.55, 0.75, 1.0)), + text("INSTALL LOCATION").size(10).style(|_: &Theme| text::Style { + color: Some(DIM), }), row![prefix_input, browse_btn] .spacing(8) @@ -476,7 +480,7 @@ fn view_picking(state: &State) -> Element<'_, Message> { text("The folder where the launcher's Wine prefix will be created.") .size(11) .style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.45, 0.45, 0.45)), + color: Some(MUTED), }), ] .spacing(6), @@ -486,13 +490,13 @@ fn view_picking(state: &State) -> Element<'_, Message> { let status_el: Element = if !state.status.is_empty() { container( text(&state.status).size(13).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(1.0, 0.55, 0.45)), + color: Some(RED), }), ) .padding(Padding::from([6, 0])) .into() } else { - text("").into() + Space::new(0, 0).into() }; // ── Next button ─────────────────────────────────────────────────────────── @@ -500,9 +504,13 @@ fn view_picking(state: &State) -> Element<'_, Message> { state.selected_template.is_some() && !state.prefix_input.trim().is_empty(); let next_btn = container( - button(text("Next →").size(14)) - .on_press_maybe(can_confirm.then_some(Message::ConfirmLauncher)) - .style(button::primary), + button( + row![text("Next").size(14), icon("\u{f138}", 14)] + .align_y(Alignment::Center).spacing(6), + ) + .on_press_maybe(can_confirm.then_some(Message::ConfirmLauncher)) + .style(btn_accent) + .padding([8, 18]), ) .padding(Padding { top: 4.0, right: 0.0, bottom: 0.0, left: 0.0 }) .width(Length::Fill) @@ -520,13 +528,14 @@ fn view_picking(state: &State) -> Element<'_, Message> { ) .width(Length::Fill) .height(Length::Fill) + .style(surface_bg) .into() } fn step_indicator<'a>(step: u8) -> Element<'a, Message> { - let active = Color::from_rgb(0.55, 0.75, 1.0); - let done = Color::from_rgb(0.4, 0.85, 0.4); - let muted = Color::from_rgb(0.4, 0.4, 0.4); + let active = ACCENT; + let done = GREEN; + let muted_clr = MUTED; let label = |n: u8, label: &'static str| -> Element<'a, Message> { let (num_color, text_color) = if n < step { @@ -534,7 +543,7 @@ fn step_indicator<'a>(step: u8) -> Element<'a, Message> { } else if n == step { (active, active) } else { - (muted, muted) + (muted_clr, muted_clr) }; row![ text(format!("{n}")).size(12).style(move |_: &Theme| text::Style { color: Some(num_color) }), @@ -551,9 +560,9 @@ fn step_indicator<'a>(step: u8) -> Element<'a, Message> { container( row![ label(1, "Choose"), - sep(if step > 1 { done } else { muted }), + sep(if step > 1 { done } else { muted_clr }), label(2, "Install"), - sep(if step > 2 { done } else { muted }), + sep(if step > 2 { done } else { muted_clr }), label(3, "Done"), ] .align_y(Alignment::Center), @@ -564,24 +573,17 @@ fn step_indicator<'a>(step: u8) -> Element<'a, Message> { fn status_card(msg: String, kind: StatusKind) -> Element<'static, Message> { let color = match kind { - StatusKind::Info => Color::from_rgb(0.55, 0.75, 1.0), - StatusKind::Success => Color::from_rgb(0.35, 0.85, 0.45), - StatusKind::Error => Color::from_rgb(1.0, 0.45, 0.35), - StatusKind::Neutral => Color::from_rgb(0.6, 0.6, 0.6), + StatusKind::Info => ACCENT, + StatusKind::Success => GREEN, + StatusKind::Error => RED, + StatusKind::Neutral => DIM, }; container( text(msg).size(13).style(move |_: &Theme| text::Style { color: Some(color) }), ) .padding(Padding::from([10, 14])) .width(Length::Fill) - .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() - } - }) + .style(card_style) .into() } @@ -607,19 +609,25 @@ fn view_install(state: &State) -> Element<'_, Message> { let can_go_back = state.selected_template.is_some() && matches!(state.stage, Stage::Idle | Stage::Ready); let back_el: Element = if state.selected_template.is_some() && !finished { - button(text("← Back").size(12)) - .on_press_maybe(can_go_back.then_some(Message::Back)) - .style(button::secondary) - .into() + button( + row![icon("\u{f12f}", 12), text(" Back").size(12)] + .align_y(Alignment::Center).spacing(4), + ) + .on_press_maybe(can_go_back.then_some(Message::Back)) + .style(btn_ghost) + .padding([6, 14]) + .into() } else { - text("").into() + Space::new(0, 0).into() }; // ── Header ──────────────────────────────────────────────────────────────── let header_row = row![ column![ steps, - text(&launcher.display).size(22), + text(&launcher.display).size(22).style(|_: &Theme| text::Style { + color: Some(ACCENT), + }), ] .spacing(0), iced::widget::horizontal_space(), @@ -630,8 +638,8 @@ fn view_install(state: &State) -> Element<'_, Message> { // ── Source / installer card (only shown in Idle, for custom URLs) ──────── let source_card: Element = if matches!(state.stage, Stage::Idle) && !is_official { let inner: Element = column![ - text("Installer").size(12).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.55, 0.75, 1.0)), + text("INSTALLER").size(10).style(|_: &Theme| text::Style { + color: Some(DIM), }), text_input("Paste a URL or local .exe path", &state.source) .on_input(Message::SourceChanged) @@ -641,7 +649,7 @@ fn view_install(state: &State) -> Element<'_, Message> { .into(); section_card(inner) } else { - text("").into() + Space::new(0, 0).into() }; // ── Status card ─────────────────────────────────────────────────────────── @@ -660,7 +668,7 @@ fn view_install(state: &State) -> Element<'_, Message> { StatusKind::Neutral, ) } else { - text("").into() + Space::new(0, 0).into() }; // ── Progress bar ────────────────────────────────────────────────────────── @@ -680,46 +688,38 @@ fn view_install(state: &State) -> Element<'_, Message> { .spacing(6), ) } else { - text("").into() + Space::new(0, 0).into() }; // ── Finished banner ─────────────────────────────────────────────────────── let finished_banner: Element = if finished { - let (icon, msg, kind) = if install_success { - ("✓", format!("{} is ready to use.", launcher.display), StatusKind::Success) + let (sym, msg) = if install_success { + ("\u{f26a}", format!("{} is ready to use.", launcher.display)) } else { - ("✗", "Installation did not complete successfully. See details below.".to_string(), StatusKind::Error) + ("\u{f28a}", "Installation did not complete successfully. See details below.".to_string()) }; + let banner_color = if install_success { GREEN } else { RED }; + let border_tint = Color { a: 0.35, ..banner_color }; container( - column![ - text(format!("{icon} {msg}")).size(15).style({ - let color = if install_success { - Color::from_rgb(0.35, 0.85, 0.45) - } else { - Color::from_rgb(1.0, 0.45, 0.35) - }; - move |_: &Theme| text::Style { color: Some(color) } + row![ + crate::theme::icon(sym, 16).style(move |_: &Theme| text::Style { + color: Some(banner_color), }), - ] + text(format!(" {msg}")).size(15).style(move |_: &Theme| text::Style { + color: Some(banner_color), + }), + ].align_y(Alignment::Center), ) .padding(Padding::from([12, 14])) .width(Length::Fill) - .style(move |theme: &Theme| { - let p = theme.extended_palette(); - let border_color = if install_success { - Color::from_rgb(0.2, 0.5, 0.25) - } else { - Color::from_rgb(0.5, 0.2, 0.2) - }; - container::Style { - background: Some(Background::Color(p.background.weak.color)), - border: Border { color: border_color, width: 1.0, radius: 6.0.into() }, - ..Default::default() - } + .style(move |_: &Theme| container::Style { + background: Some(Background::Color(SURFACE_RAISED)), + border: Border { color: border_tint, width: 1.0, radius: 8.0.into() }, + ..Default::default() }) .into() } else { - text("").into() + Space::new(0, 0).into() }; // ── Log toggle + pane ───────────────────────────────────────────────────── @@ -728,14 +728,15 @@ fn view_install(state: &State) -> Element<'_, Message> { let toggle_label = if state.show_log { "Hide details ▲" } else { "Show details ▼" }; let toggle_btn = button(text(toggle_label).size(12)) .on_press(Message::ToggleLog) - .style(button::secondary); + .style(btn_ghost) + .padding([6, 12]); if state.show_log { let lines: Vec = state.log.lock().map(|v| v.clone()).unwrap_or_default(); let rows: Vec> = lines .iter().rev().take(80).rev() .map(|l| text(l.clone()).size(11).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.55, 0.55, 0.55)), + color: Some(MUTED), }).into()) .collect(); column![ @@ -746,14 +747,7 @@ fn view_install(state: &State) -> Element<'_, Message> { ) .padding(Padding::from([6, 10])) .width(Length::Fill) - .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: 4.0.into() }, - ..Default::default() - } - }), + .style(sub_card_style), ] .spacing(6) .into() @@ -761,7 +755,7 @@ fn view_install(state: &State) -> Element<'_, Message> { toggle_btn.into() } } else { - text("").into() + Space::new(0, 0).into() }; // ── Action button ───────────────────────────────────────────────────────── @@ -769,34 +763,50 @@ fn view_install(state: &State) -> Element<'_, Message> { Stage::Idle => { let ready = is_official || !state.source.trim().is_empty(); container( - button(text("Download →").size(14)) - .on_press_maybe(ready.then_some(Message::PreparePressed)) - .style(button::primary), + button( + row![text("Download").size(14), text(" →").size(14)] + .align_y(Alignment::Center), + ) + .on_press_maybe(ready.then_some(Message::PreparePressed)) + .style(btn_accent) + .padding([8, 18]), ) .width(Length::Fill) .align_x(Alignment::End) .into() } Stage::Busy => container( - button(text("Downloading…").size(14)) - .on_press_maybe(None::) - .style(button::primary), + button( + row![crate::theme::icon("\u{f130}", 13), text(" Downloading…").size(14)] + .align_y(Alignment::Center).spacing(4), + ) + .on_press_maybe(None::) + .style(btn_ghost) + .padding([8, 18]), ) .width(Length::Fill) .align_x(Alignment::End) .into(), Stage::Ready => container( - button(text("Install →").size(14)) - .on_press(Message::InstallPressed) - .style(button::primary), + button( + row![text("Install").size(14), text(" →").size(14)] + .align_y(Alignment::Center), + ) + .on_press(Message::InstallPressed) + .style(btn_accent) + .padding([8, 18]), ) .width(Length::Fill) .align_x(Alignment::End) .into(), Stage::Installing => container( - button(text("Installing…").size(14)) - .on_press_maybe(None::) - .style(button::primary), + button( + row![crate::theme::icon("\u{f130}", 13), text(" Installing…").size(14)] + .align_y(Alignment::Center).spacing(4), + ) + .on_press_maybe(None::) + .style(btn_ghost) + .padding([8, 18]), ) .width(Length::Fill) .align_x(Alignment::End) @@ -804,10 +814,15 @@ fn view_install(state: &State) -> Element<'_, Message> { Stage::Finished => { let close_btn = button(text("Close").size(13)) .on_press(Message::Close) - .style(button::secondary); - let launch_btn = button(text("Open Launcher").size(13)) - .on_press_maybe(install_success.then_some(Message::LaunchNow)) - .style(button::primary); + .style(btn_ghost) + .padding([8, 14]); + let launch_btn = button( + row![crate::theme::icon("\u{f4f4}", 13), text(" Open Launcher").size(13)] + .align_y(Alignment::Center).spacing(4), + ) + .on_press_maybe(install_success.then_some(Message::LaunchNow)) + .style(btn_accent) + .padding([8, 14]); container( row![close_btn, launch_btn].spacing(10), ) @@ -815,7 +830,7 @@ fn view_install(state: &State) -> Element<'_, Message> { .align_x(Alignment::End) .into() } - Stage::Picking => text("").into(), + Stage::Picking => Space::new(0, 0).into(), }; // ── Assembly ────────────────────────────────────────────────────────────── @@ -834,6 +849,7 @@ fn view_install(state: &State) -> Element<'_, Message> { container(scrollable(body)) .width(Length::Fill) .height(Length::Fill) + .style(surface_bg) .into() } @@ -934,15 +950,7 @@ fn run_installer( log: Arc>>, ) -> Result { std::fs::create_dir_all(&launcher.prefix_dir).map_err(|e| e.to_string())?; - let version = launcher - .proton_version - .as_deref() - .unwrap_or(&config.proton_version); - let proton_path: OsString = if version == "GE-Proton" { - version.to_string().into() - } else { - config.proton_compat_dir.join(version).into_os_string() - }; + let proton_path = crate::launcher::resolve_proton_path(config, launcher); let mut child = Command::new("umu-run") .env("WINEPREFIX", &launcher.prefix_dir) .env("GAMEID", &launcher.gameid) diff --git a/src/theme.rs b/src/theme.rs new file mode 100644 index 0000000..f4953c7 --- /dev/null +++ b/src/theme.rs @@ -0,0 +1,205 @@ +use iced::widget::{button, container, text}; +use iced::{Background, Border, Color, Element, Shadow, Theme, Vector}; + +// ── Palette ──────────────────────────────────────────────────────────────── + +pub const ACCENT: Color = Color { + r: 0.49, + g: 0.55, + b: 0.97, + a: 1.0, +}; +pub const GREEN: Color = Color { + r: 0.29, + g: 0.87, + b: 0.50, + a: 1.0, +}; +pub const RED: Color = Color { + r: 0.97, + g: 0.44, + b: 0.44, + a: 1.0, +}; +pub const MUTED: Color = Color { + r: 0.42, + g: 0.44, + b: 0.50, + a: 1.0, +}; +pub const DIM: Color = Color { + r: 0.55, + g: 0.58, + b: 0.64, + a: 1.0, +}; +pub const SURFACE: Color = Color { + r: 0.12, + g: 0.13, + b: 0.16, + a: 1.0, +}; +pub const SURFACE_RAISED: Color = Color { + r: 0.15, + g: 0.16, + b: 0.19, + a: 1.0, +}; +pub const BORDER_CLR: Color = Color { + r: 0.20, + g: 0.21, + b: 0.26, + a: 1.0, +}; + +// ── Helpers ──────────────────────────────────────────────────────────────── + +/// Bootstrap-icon helper — keeps call sites tidy. +pub fn icon(codepoint: &str, size: u16) -> iced::widget::Text<'static> { + text(codepoint.to_owned()) + .font(iced::Font::with_name("bootstrap-icons")) + .size(size) +} + +/// Styled section heading (uppercase, dimmed). +pub fn section_heading<'a, M: 'a>(label: &str) -> Element<'a, M> { + text(label.to_uppercase()) + .size(10) + .style(move |_: &Theme| text::Style { color: Some(DIM) }) + .into() +} + +// ── Button styles ────────────────────────────────────────────────────────── + +/// Accent-filled primary button style. +pub fn btn_accent(_theme: &Theme, status: button::Status) -> button::Style { + let (bg, fg) = match status { + button::Status::Active => (ACCENT, Color::WHITE), + button::Status::Hovered => (Color { a: 0.85, ..ACCENT }, Color::WHITE), + _ => ( + Color { a: 0.5, ..ACCENT }, + Color { + a: 0.7, + ..Color::WHITE + }, + ), + }; + button::Style { + background: Some(Background::Color(bg)), + text_color: fg, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + shadow: NO_SHADOW, + } +} + +pub const NO_SHADOW: Shadow = Shadow { + color: Color::TRANSPARENT, + offset: Vector::ZERO, + blur_radius: 0.0, +}; + +/// Ghost / outline secondary button style. +pub fn btn_ghost(_theme: &Theme, status: button::Status) -> button::Style { + let (bg, border_a) = match status { + button::Status::Hovered => ( + Color { r: 0.22, g: 0.23, b: 0.28, a: 1.0 }, + 0.40, + ), + button::Status::Pressed => ( + Color { r: 0.25, g: 0.26, b: 0.31, a: 1.0 }, + 0.50, + ), + _ => ( + Color { r: 0.18, g: 0.19, b: 0.23, a: 1.0 }, + 0.25, + ), + }; + button::Style { + background: Some(Background::Color(bg)), + text_color: Color { + r: 0.78, + g: 0.80, + b: 0.85, + a: 1.0, + }, + border: Border { + color: Color { a: border_a, ..BORDER_CLR }, + width: 1.0, + radius: 8.0.into(), + }, + shadow: NO_SHADOW, + } +} + +/// Red danger button style. +pub fn btn_danger(_theme: &Theme, status: button::Status) -> button::Style { + let (bg, fg) = match status { + button::Status::Active => (RED, Color::WHITE), + button::Status::Hovered => (Color { a: 0.85, ..RED }, Color::WHITE), + _ => ( + Color { a: 0.5, ..RED }, + Color { + a: 0.7, + ..Color::WHITE + }, + ), + }; + button::Style { + background: Some(Background::Color(bg)), + text_color: fg, + border: Border { + color: Color::TRANSPARENT, + width: 0.0, + radius: 8.0.into(), + }, + shadow: NO_SHADOW, + } +} + +// ── Container styles ─────────────────────────────────────────────────────── + +/// Full-window background style. +pub fn surface_bg(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(SURFACE)), + ..Default::default() + } +} + +/// Raised card style (12px radius, 1px border). +pub fn card_style(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(SURFACE_RAISED)), + border: Border { + color: BORDER_CLR, + width: 1.0, + radius: 12.0.into(), + }, + ..Default::default() + } +} + +/// Inner sub-card style (darker background, subtle border, 8px radius). +pub fn sub_card_style(_theme: &Theme) -> container::Style { + container::Style { + background: Some(Background::Color(Color { + r: 0.11, + g: 0.12, + b: 0.15, + a: 0.8, + })), + border: Border { + color: Color { + a: 0.15, + ..BORDER_CLR + }, + width: 1.0, + radius: 8.0.into(), + }, + ..Default::default() + } +} diff --git a/src/tray.rs b/src/tray.rs index 90a8e1f..37504c0 100644 --- a/src/tray.rs +++ b/src/tray.rs @@ -298,6 +298,26 @@ impl ksni::Tray for UmuTray { /// Start the system tray daemon. Blocks until the process is killed. pub fn run(config: &Config) -> Result<()> { + let _handle = spawn(config); + loop { + thread::sleep(Duration::from_secs(60)); + } +} + +/// A handle that can shut down the tray from another thread. +pub struct TrayHandle { + inner: ksni::Handle, +} + +impl TrayHandle { + pub fn shutdown(&self) { + let h = self.inner.clone(); + thread::spawn(move || h.shutdown()); + } +} + +/// Spawn the tray icon in the background and return a handle to shut it down. +pub fn spawn(config: &Config) -> TrayHandle { let mut running = HashMap::new(); for l in &config.launchers { running.insert(l.name.clone(), launcher::is_running(l)); @@ -321,7 +341,7 @@ pub fn run(config: &Config) -> Result<()> { // Background thread: poll every configured launcher's state every 2 s // and push the snapshot to the tray. - let poll_handle = handle; + let poll_handle = handle.clone(); let launchers = config.launchers.clone(); thread::spawn(move || loop { let mut snapshot: HashMap = HashMap::new(); @@ -334,7 +354,5 @@ pub fn run(config: &Config) -> Result<()> { thread::sleep(Duration::from_secs(2)); }); - loop { - thread::sleep(Duration::from_secs(60)); - } + TrayHandle { inner: handle } } diff --git a/umutray.service b/umutray.service deleted file mode 100644 index 0be8d65..0000000 --- a/umutray.service +++ /dev/null @@ -1,12 +0,0 @@ -[Unit] -Description=umutray Wine launcher manager -After=graphical-session.target -PartOf=graphical-session.target - -[Service] -ExecStart=/usr/bin/umutray -Restart=on-failure -RestartSec=5 - -[Install] -WantedBy=graphical-session.target