Redesign launcher cards: icon buttons, proton badge, pill toggles, sub-cards, better header

This commit is contained in:
funman300
2026-04-19 01:04:30 -07:00
parent 3c1742174b
commit d3ac300b91
+289 -249
View File
@@ -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>>) -> 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<Message> = {
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<Message> = 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),
})
)
.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,
let mut sections: Vec<Element<Message>> = vec![header.into()];
// ── Games section ─────────────────────────────────────────────────────
let has_games = !l.games.is_empty();
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()
} else if game_count == 1 {
"1 Game".to_string()
} else {
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)
)
.on_press_maybe((!scan_busy).then_some(Message::ScanGames(lname_scan)))
.style(button::secondary)
.padding([3, 6]);
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 rows: Vec<Element<Message>> = vec![
column![name_row, status_row].spacing(3).into(),
];
let mut game_items: Vec<Element<Message>> = vec![section_header.into()];
if l.games.is_empty() && scan_results.map(|r| r.is_empty()).unwrap_or(true) && !scan_busy {
// Scanning indicator
if scan_busy {
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) }),
text(" Scanning for games…").size(12)
.style(move |_: &Theme| text::Style { color: Some(accent) }),
].align_y(Alignment::Center),
)
.padding(Padding::from([6, 0]))
.into(),
);
}
// Detected (unregistered) games
if let Some(results) = scan_results {
for (display, exe) in results {
let lname = l.name.clone();
let d = display.clone();
let e = exe.clone();
let add_btn = button(
row![
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(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(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(),
);
}
}
// 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)
)
.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(),
);
} else {
// Empty state — no games, no scan results
let lname = l.name.clone();
let lname2 = l.name.clone();
let scan_btn = button(text("Scan for games").size(11))
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),
)
.on_press(Message::ScanGames(lname))
.style(button::secondary);
let browse_btn = button(text("Browse exe…").size(11))
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);
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),
sections.push(sub_card(
row![
text("No games added yet.").size(12).style(|_: &Theme| text::Style {
color: Some(Color::from_rgb(0.45, 0.45, 0.45)),
}),
text("No games configured").size(12)
.style(move |_: &Theme| text::Style { color: Some(muted) }),
iced::widget::horizontal_space(),
scan_btn,
browse_btn,
]
.align_y(Alignment::Center)
.spacing(6),
]
.spacing(6),
)
.padding(Padding { top: 8.0, left: 2.0, right: 0.0, bottom: 0.0 })
.into(),
);
}
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(),
);
}
if let Some(results) = scan_results {
if !results.is_empty() {
rows.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),
)
.padding(Padding { top: 6.0, left: 2.0, right: 0.0, bottom: 0.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(
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 }),
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)),
}),
].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(),
);
}
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),
)
.padding(Padding { top: 8.0, left: 2.0, right: 0.0, bottom: 0.0 })
.into(),
);
}
for g in &l.games {
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(
row![
play,
container(install_indicator).padding(Padding { top: 0.0, left: 2.0, right: 4.0, bottom: 0.0 }),
text(&g.display).size(13),
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 }),
]
.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());
));
}
// ── Add game form ─────────────────────────────────────────────────────
if let Some((name_val, exe_val)) = add_form {
let lname = l.name.clone();
let lname2 = l.name.clone();
@@ -1080,18 +1117,20 @@ 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(
sections.push(sub_card(
column![
text("Add Game Manually").size(12).style(|_: &Theme| text::Style {
color: Some(Color::from_rgb(0.5, 0.5, 0.5)),
}),
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),
@@ -1102,32 +1141,35 @@ fn launcher_card<'a>(
.align_x(Alignment::End),
]
.spacing(6),
)
.padding(Padding { top: 8.0, left: 0.0, right: 0.0, bottom: 0.0 })
.into(),
);
));
}
// ── 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(
sections.push(
row![
add_game_btn,
iced::widget::horizontal_space(),
@@ -1135,13 +1177,11 @@ fn launcher_card<'a>(
browse_btn,
]
.align_y(Alignment::Center)
.spacing(6),
)
.padding(Padding { top: 8.0, right: 0.0, bottom: 0.0, left: 0.0 })
.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> {