From d3ac300b91f9b8bcb684cf2b3122ee43756076c4 Mon Sep 17 00:00:00 2001 From: funman300 Date: Sun, 19 Apr 2026 01:04:30 -0700 Subject: [PATCH] Redesign launcher cards: icon buttons, proton badge, pill toggles, sub-cards, better header --- src/gui.rs | 560 ++++++++++++++++++++++++++++------------------------- 1 file changed, 300 insertions(+), 260 deletions(-) diff --git a/src/gui.rs b/src/gui.rs index 6c0e983..fe6fb8f 100644 --- a/src/gui.rs +++ b/src/gui.rs @@ -778,6 +778,29 @@ fn view(state: &Dashboard) -> Element<'_, Message> { // ── Card views ────────────────────────────────────────────────────────────── +/// 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])) + .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() + } + }) + .into() +} + fn launcher_card<'a>( l: &'a crate::config::Launcher, installed: bool, @@ -786,280 +809,294 @@ fn launcher_card<'a>( scan_results: Option<&'a Vec<(String, String)>>, scan_busy: bool, ) -> Element<'a, Message> { - let status_color = if running { - Color::from_rgb(0.35, 0.85, 0.45) - } else if installed { - Color::from_rgb(0.55, 0.75, 1.0) - } else { - Color::from_rgb(0.45, 0.45, 0.45) - }; - let status_dot = if running { "●" } else if installed { "●" } else { "○" }; - let status_str = if running { "Running" } else if installed { "Installed" } else { "Not installed" }; + // ── 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" }; + + // ── Action button ───────────────────────────────────────────────────── let action: Element = { let n = l.name.clone(); if !installed { - button(text("Setup").size(13)) + button( + row![ + text("\u{f3e2}").font(iced::Font::with_name("bootstrap-icons")).size(12), + text(" Setup").size(13), + ].align_y(Alignment::Center).spacing(4), + ) .on_press(Message::Setup(n)) .style(button::secondary) .into() } else if running { - button(text("Kill").size(13)) + button( + row![ + text("\u{f5de}").font(iced::Font::with_name("bootstrap-icons")).size(12), + text(" Stop").size(13), + ].align_y(Alignment::Center).spacing(4), + ) .on_press(Message::Kill(n)) .style(button::danger) .into() } else { - button(text("Launch").size(13)) + button( + row![ + text("\u{f4f4}").font(iced::Font::with_name("bootstrap-icons")).size(12), + text(" Launch").size(13), + ].align_y(Alignment::Center).spacing(4), + ) .on_press(Message::Launch(n)) .style(button::primary) .into() } }; + // ── Proton version badge ────────────────────────────────────────────── let version_badge: Element = if let Some(v) = &l.proton_version { - text(format!(" {v}")) - .size(10) - .style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.45, 0.55, 0.7)), + container( + text(v).size(10).style(move |_: &Theme| text::Style { + color: Some(accent), }) - .into() + ) + .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() }, + ..Default::default() + }) + .into() } else { text("").into() }; - let name_row = row![ - text(&l.display).size(15), + // ── Header ──────────────────────────────────────────────────────────── + let header = row![ + column![ + row![ + text(&l.display).size(17), + 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) }), + ].align_y(Alignment::Center), + ].spacing(4), iced::widget::horizontal_space(), action, ] .align_y(Alignment::Center); - let status_row = row![ - text(status_dot).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), - }), - version_badge, - ] - .align_y(Alignment::Center); + let mut sections: Vec> = vec![header.into()]; - let mut rows: Vec> = vec![ - column![name_row, status_row].spacing(3).into(), - ]; + // ── Games section ───────────────────────────────────────────────────── + let has_games = !l.games.is_empty(); + let has_scan = scan_results.map(|r| !r.is_empty()).unwrap_or(false); - 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( - container( - column![ - text("Games").size(12).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.5, 0.5, 0.5)), - }), - iced::widget::horizontal_rule(1), - row![ - text("No games added yet.").size(12).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.45, 0.45, 0.45)), - }), - iced::widget::horizontal_space(), - scan_btn, - browse_btn, - ] - .align_y(Alignment::Center) - .spacing(6), - ] - .spacing(6), - ) - .padding(Padding { top: 8.0, left: 2.0, right: 0.0, bottom: 0.0 }) - .into(), - ); - } + 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() + } else if game_count == 1 { + "1 Game".to_string() + } else { + format!("{game_count} Games") + }; - if scan_busy { - rows.push( - container( - row![ - text("⟳ Scanning for games…").size(12).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.55, 0.75, 1.0)), - }), - ] - ) - .padding(Padding { top: 6.0, left: 2.0, right: 0.0, bottom: 0.0 }) - .into(), - ); - } + let lname_scan = l.name.clone(); + let rescan_btn = button( + text("\u{f130}").font(iced::Font::with_name("bootstrap-icons")).size(11) + ) + .on_press_maybe((!scan_busy).then_some(Message::ScanGames(lname_scan))) + .style(button::secondary) + .padding([3, 6]); - if let Some(results) = scan_results { - if !results.is_empty() { - rows.push( + let section_header = row![ + text(section_label).size(11) + .style(move |_: &Theme| text::Style { color: Some(subtle) }), + iced::widget::horizontal_space(), + rescan_btn, + ] + .align_y(Alignment::Center); + + let mut game_items: Vec> = vec![section_header.into()]; + + // Scanning indicator + if scan_busy { + game_items.push( container( - column![ - text("Detected Games").size(12).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.5, 0.5, 0.5)), - }), - iced::widget::horizontal_rule(1), - ] - .spacing(4), + row![ + text("\u{f130}").font(iced::Font::with_name("bootstrap-icons")).size(12) + .style(move |_: &Theme| text::Style { color: Some(accent) }), + text(" Scanning for games…").size(12) + .style(move |_: &Theme| text::Style { color: Some(accent) }), + ].align_y(Alignment::Center), ) - .padding(Padding { top: 6.0, left: 2.0, right: 0.0, bottom: 0.0 }) + .padding(Padding::from([6, 0])) .into(), ); } - for (display, exe) in results { - let lname = l.name.clone(); - let d = display.clone(); - let e = exe.clone(); - let add_btn = button( - text("\u{f64d}").font(iced::Font::with_name("bootstrap-icons")).size(12) - ) - .on_press(Message::AddScannedGame(lname, d, e)) - .style(button::primary) - .padding([4, 8]); - rows.push( - container( + + // Detected (unregistered) games + if let Some(results) = scan_results { + for (display, exe) in results { + let lname = l.name.clone(); + let d = display.clone(); + let e = exe.clone(); + let add_btn = button( row![ - container( - text("○").size(8).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.4, 0.4, 0.4)), - }) - ).padding(Padding { top: 0.0, left: 4.0, right: 4.0, bottom: 0.0 }), + text("\u{f64d}").font(iced::Font::with_name("bootstrap-icons")).size(11), + text(" Add").size(11), + ].align_y(Alignment::Center).spacing(3), + ) + .on_press(Message::AddScannedGame(lname, d, e)) + .style(button::primary) + .padding([4, 8]); + + game_items.push(sub_card( + row![ + text("◇").size(10).style(move |_: &Theme| text::Style { color: Some(dim) }), column![ - text(display).size(13).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.8, 0.8, 0.8)), - }), - text(exe).size(10).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.4, 0.4, 0.4)), - }), + text(display).size(12), + text(exe).size(9).style(move |_: &Theme| text::Style { color: Some(dim) }), ].spacing(1), iced::widget::horizontal_space(), add_btn, ] .align_y(Alignment::Center) - .spacing(4), - ) - .padding(Padding { top: 2.0, left: 2.0, right: 0.0, bottom: 2.0 }) - .into(), - ); + .spacing(8), + )); + } + if results.is_empty() && !scan_busy { + game_items.push( + container( + text("No additional games detected.").size(11) + .style(move |_: &Theme| text::Style { color: Some(muted) }) + ) + .padding(Padding::from([4, 0])) + .into(), + ); + } } - if results.is_empty() && !scan_busy { - rows.push( - container( - text("No new games found in this prefix.").size(11).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.45, 0.45, 0.45)), - }), - ) - .padding(Padding { top: 6.0, left: 2.0, right: 0.0, bottom: 0.0 }) - .into(), - ); - } - } - // ── Configured games section ──────────────────────────────────────────── - if !l.games.is_empty() { - rows.push( - container( - column![ - text("Games").size(12).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.5, 0.5, 0.5)), - }), - iced::widget::horizontal_rule(1), - ] - .spacing(4), + // Configured games + for g in &l.games { + let lname = l.name.clone(); + let gname = g.name.clone(); + let game_installed = g.full_exe_path(l).exists(); + + // Play button + let play = button( + text("\u{f4f4}").font(iced::Font::with_name("bootstrap-icons")).size(13) ) - .padding(Padding { top: 8.0, left: 2.0, right: 0.0, bottom: 0.0 }) - .into(), + .on_press_maybe(game_installed.then_some(Message::Play(lname.clone(), gname.clone()))) + .style(button::primary) + .padding([6, 10]); + + // Status dot + let status_dot_color = if game_installed { green } else { muted }; + let status_dot = text("●").size(7).style(move |_: &Theme| text::Style { + color: Some(status_dot_color), + }); + + // Overlay toggle pills + let gm_active = g.gamemode; + let hud_active = g.mangohud; + let gs_active = g.gamescope.is_some(); + + let pill = |label: &'static str, active: bool, msg: Message| -> Element<'a, Message> { + let (fg, bg_alpha) = if active { + (accent, 0.18) + } else { + (muted, 0.0) + }; + 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() + }; + + let overlays = row![ + pill("GM", gm_active, Message::ToggleGameMode(lname.clone(), gname.clone())), + pill("HUD", hud_active, Message::ToggleMangoHud(lname.clone(), gname.clone())), + pill("GS", gs_active, Message::ToggleGamescope(lname.clone(), gname.clone())), + ].spacing(4); + + // Remove + let remove_game = button( + text("\u{f659}").font(iced::Font::with_name("bootstrap-icons")).size(11) + ) + .on_press(Message::RemoveGame(lname, gname)) + .style(button::danger) + .padding([3, 6]); + + 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), + 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), + )); + } + + sections.push( + Column::with_children(game_items).spacing(4).into(), ); - } - - for g in &l.games { + } else { + // Empty state — no games, no scan results let lname = l.name.clone(); - let gname = g.name.clone(); - let game_installed = g.full_exe_path(l).exists(); - - let play = button( - text("▶").size(12) - ) - .on_press_maybe(game_installed.then_some(Message::Play(lname.clone(), gname.clone()))) - .style(button::primary) - .padding([5, 10]); - - let gm_label = if g.gamemode { "GM ✓" } else { "GM" }; - let hud_label = if g.mangohud { "HUD ✓" } else { "HUD" }; - let gs_label = if g.gamescope.is_some() { "GS ✓" } else { "GS" }; - - let gm_btn = button(text(gm_label).size(10)) - .on_press(Message::ToggleGameMode(lname.clone(), gname.clone())) - .style(if g.gamemode { button::primary } else { button::secondary }) - .padding([3, 7]); - - let hud_btn = button(text(hud_label).size(10)) - .on_press(Message::ToggleMangoHud(lname.clone(), gname.clone())) - .style(if g.mangohud { button::primary } else { button::secondary }) - .padding([3, 7]); - - let gs_btn = button(text(gs_label).size(10)) - .on_press(Message::ToggleGamescope(lname.clone(), gname.clone())) - .style(if g.gamescope.is_some() { button::primary } else { button::secondary }) - .padding([3, 7]); - - let remove_game = button( - text("✕").size(10) - ) - .on_press(Message::RemoveGame(lname, gname)) - .style(button::danger) - .padding([3, 7]); - - let install_indicator = if game_installed { - text("●").size(8).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.35, 0.85, 0.45)), - }) - } else { - text("●").size(8).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.5, 0.5, 0.5)), - }) - }; - - let game_row = container( + let lname2 = l.name.clone(); + let scan_btn = button( row![ - play, - container(install_indicator).padding(Padding { top: 0.0, left: 2.0, right: 4.0, bottom: 0.0 }), - text(&g.display).size(13), + text("\u{f130}").font(iced::Font::with_name("bootstrap-icons")).size(11), + text(" Scan for games").size(11), + ].align_y(Alignment::Center).spacing(4), + ) + .on_press(Message::ScanGames(lname)) + .style(button::secondary); + 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), + ) + .on_press(Message::BrowseGameExe(lname2)) + .style(button::secondary); + + sections.push(sub_card( + row![ + text("No games configured").size(12) + .style(move |_: &Theme| text::Style { color: Some(muted) }), iced::widget::horizontal_space(), - row![gm_btn, hud_btn, gs_btn].spacing(3), - container(remove_game).padding(Padding { top: 0.0, left: 6.0, right: 0.0, bottom: 0.0 }), + scan_btn, + browse_btn, ] .align_y(Alignment::Center) - .spacing(4), - ) - .padding(Padding { top: 3.0, left: 0.0, right: 0.0, bottom: 3.0 }) - .width(Length::Fill) - .style(|theme: &Theme| { - let p = theme.extended_palette(); - container::Style { - background: Some(Background::Color(Color { - a: 0.3, - ..p.background.strong.color - })), - border: Border { - color: Color::TRANSPARENT, - width: 0.0, - radius: 4.0.into(), - }, - ..Default::default() - } - }); - - rows.push(game_row.into()); + .spacing(6), + )); } + // ── Add game form ───────────────────────────────────────────────────── if let Some((name_val, exe_val)) = add_form { let lname = l.name.clone(); let lname2 = l.name.clone(); @@ -1080,68 +1117,71 @@ fn launcher_card<'a>( .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 Game").size(11)) + 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), + ) .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( - container( - column![ - text("Add Game Manually").size(12).style(|_: &Theme| text::Style { - color: Some(Color::from_rgb(0.5, 0.5, 0.5)), - }), - row![name_input, exe_input, browse_btn] - .align_y(Alignment::Center) - .spacing(6), - container( - row![cancel_btn, confirm_btn].spacing(6), - ) - .width(Length::Fill) - .align_x(Alignment::End), - ] - .spacing(6), - ) - .padding(Padding { top: 8.0, left: 0.0, right: 0.0, bottom: 0.0 }) - .into(), - ); + sections.push(sub_card( + column![ + text("Add Game").size(11).style(move |_: &Theme| text::Style { color: Some(subtle) }), + row![name_input, exe_input, browse_btn] + .align_y(Alignment::Center) + .spacing(6), + container( + row![cancel_btn, confirm_btn].spacing(6), + ) + .width(Length::Fill) + .align_x(Alignment::End), + ] + .spacing(6), + )); } - // ── Game toolbar ────────────────────────────────────────────────────────── + // ── Toolbar ─────────────────────────────────────────────────────────── let lname_add = l.name.clone(); let lname_scan = l.name.clone(); let lname_browse = l.name.clone(); - let add_label = if add_form.is_some() { "Cancel" } else { "+ Add Game" }; - let add_game_btn = button(text(add_label).size(11)) + 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); + .style(button::secondary) + .padding([3, 8]); - let scan_btn = button(text("Scan").size(11)) + let scan_btn = button( + text("\u{f130}").font(iced::Font::with_name("bootstrap-icons")).size(10) + ) .on_press_maybe((!scan_busy).then_some(Message::ScanGames(lname_scan))) - .style(button::secondary); + .style(button::secondary) + .padding([3, 6]); - let browse_btn = button(text("Browse exe…").size(11)) + let browse_btn = button( + text("\u{f3e8}").font(iced::Font::with_name("bootstrap-icons")).size(10) + ) .on_press(Message::BrowseGameExe(lname_browse)) - .style(button::secondary); + .style(button::secondary) + .padding([3, 6]); - rows.push( - container( - row![ - add_game_btn, - iced::widget::horizontal_space(), - scan_btn, - browse_btn, - ] - .align_y(Alignment::Center) - .spacing(6), - ) - .padding(Padding { top: 8.0, right: 0.0, bottom: 0.0, left: 0.0 }) + sections.push( + row![ + add_game_btn, + iced::widget::horizontal_space(), + scan_btn, + browse_btn, + ] + .align_y(Alignment::Center) + .spacing(4) .into(), ); - Column::with_children(rows).spacing(5).into() + Column::with_children(sections).spacing(10).into() } fn context_menu_card(l: &crate::config::Launcher) -> Element<'_, Message> {