Implement UI/UX overhaul - cards, list, tabbed detail, context menu

Card view: 200px cards with 72px icons, .title-3 names, version+size
combined line, single priority badge, libadwaita .card class replacing
custom .app-card CSS.

List view: 48px rounded icons, .rich-list class, structured two-line
subtitle (description + version/size), single priority badge.

Detail view: restructured into ViewStack/ViewSwitcher with 4 tabs
(Overview, System, Security, Storage). 96px hero banner with gradient
background. Rows distributed logically across tabs.

Context menu: right-click (GestureClick button 3) and long-press on
cards and list rows. Menu items: Launch, Check for Updates, Scan for
Vulnerabilities, Integrate/Remove Integration, Open Containing Folder,
Copy Path. All backed by parameterized window actions.

CSS: removed custom .app-card rules (replaced by .card), added
.icon-rounded for list icons, .detail-banner gradient, and
.detail-view-switcher positioning.
This commit is contained in:
lashman
2026-02-27 11:10:23 +02:00
parent 4f7d8560f1
commit 33cc8a757a
7 changed files with 2630 additions and 397 deletions

View File

@@ -4,8 +4,6 @@ use std::cell::{Cell, RefCell};
use std::rc::Rc;
use crate::core::database::AppImageRecord;
use crate::core::fuse::FuseStatus;
use crate::core::wayland::WaylandStatus;
use crate::i18n::{i18n, i18n_f, ni18n_f};
use super::app_card;
use super::widgets;
@@ -195,16 +193,16 @@ impl LibraryView {
// Grid view
let flow_box = gtk::FlowBox::builder()
.valign(gtk::Align::Start)
.selection_mode(gtk::SelectionMode::Single)
.selection_mode(gtk::SelectionMode::None)
.homogeneous(true)
.min_children_per_line(2)
.max_children_per_line(6)
.row_spacing(12)
.column_spacing(12)
.margin_top(12)
.margin_bottom(12)
.margin_start(12)
.margin_end(12)
.max_children_per_line(4)
.row_spacing(14)
.column_spacing(14)
.margin_top(14)
.margin_bottom(14)
.margin_start(14)
.margin_end(14)
.build();
flow_box.update_property(&[AccessibleProperty::Label("AppImage library grid")]);
@@ -216,9 +214,10 @@ impl LibraryView {
// List view
let list_box = gtk::ListBox::builder()
.selection_mode(gtk::SelectionMode::Single)
.selection_mode(gtk::SelectionMode::None)
.build();
list_box.add_css_class("boxed-list");
list_box.add_css_class("rich-list");
list_box.update_property(&[AccessibleProperty::Label("AppImage library list")]);
let list_clamp = adw::Clamp::builder()
@@ -430,10 +429,14 @@ impl LibraryView {
for record in &new_records {
// Grid card
let card = app_card::build_app_card(record);
let card_menu = build_context_menu(record);
attach_context_menu(&card, &card_menu);
self.flow_box.append(&card);
// List row
let row = self.build_list_row(record);
let row_menu = build_context_menu(record);
attach_context_menu(&row, &row_menu);
self.list_box.append(&row);
}
@@ -459,83 +462,55 @@ impl LibraryView {
fn build_list_row(&self, record: &AppImageRecord) -> adw::ActionRow {
let name = record.app_name.as_deref().unwrap_or(&record.filename);
// Richer subtitle with description snippet when available
let subtitle = {
let mut parts = Vec::new();
if let Some(ref ver) = record.app_version {
parts.push(ver.clone());
}
parts.push(widgets::format_size(record.size_bytes));
if let Some(ref desc) = record.description {
if !desc.is_empty() {
// Truncate description to first sentence or 60 chars
let snippet: String = desc.chars().take(60).collect();
let snippet = if snippet.len() < desc.len() {
format!("{}...", snippet.trim_end())
} else {
snippet
};
parts.push(snippet);
// Structured two-line subtitle:
// Line 1: Description snippet or file path
// Line 2: Version + size
let line1 = if let Some(ref desc) = record.description {
if !desc.is_empty() {
let snippet: String = desc.chars().take(60).collect();
if snippet.len() < desc.len() {
format!("{}...", snippet.trim_end())
} else {
snippet
}
} else {
record.path.clone()
}
parts.join(" - ")
} else {
record.path.clone()
};
let mut meta_parts = Vec::new();
if let Some(ref ver) = record.app_version {
meta_parts.push(ver.clone());
}
meta_parts.push(widgets::format_size(record.size_bytes));
let line2 = meta_parts.join(" - ");
let subtitle = format!("{}\n{}", line1, line2);
let row = adw::ActionRow::builder()
.title(name)
.subtitle(&subtitle)
.subtitle_lines(2)
.activatable(true)
.build();
// Icon prefix (40x40 with letter fallback)
// Icon prefix (48x48 with rounded clipping and letter fallback)
let icon = widgets::app_icon(
record.icon_path.as_deref(),
name,
40,
48,
);
icon.add_css_class("icon-rounded");
row.add_prefix(&icon);
// Status badges as suffix
let badge_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(4)
.valign(gtk::Align::Center)
.build();
// Wayland badge
if let Some(ref ws) = record.wayland_status {
let status = WaylandStatus::from_str(ws);
if status != WaylandStatus::Unknown && status != WaylandStatus::Native {
let badge = widgets::status_badge(status.label(), status.badge_class());
badge_box.append(&badge);
}
// Single most important badge as suffix (same priority as cards)
if let Some(badge) = app_card::build_priority_badge(record) {
badge.set_valign(gtk::Align::Center);
row.add_suffix(&badge);
}
// FUSE badge
if let Some(ref fs) = record.fuse_status {
let status = FuseStatus::from_str(fs);
if !status.is_functional() {
let badge = widgets::status_badge(status.label(), status.badge_class());
badge_box.append(&badge);
}
}
// Update badge
if let (Some(ref latest), Some(ref current)) =
(&record.latest_version, &record.app_version)
{
if crate::core::updater::version_is_newer(latest, current) {
let badge = widgets::status_badge("Update", "info");
badge_box.append(&badge);
}
}
// Integration badge
let int_badge = widgets::integration_badge(record.integrated);
badge_box.append(&int_badge);
row.add_suffix(&badge_box);
// Navigate arrow
let arrow = gtk::Image::from_icon_name("go-next-symbolic");
row.add_suffix(&arrow);
@@ -595,3 +570,61 @@ impl LibraryView {
}
}
}
/// Build the right-click context menu model for an AppImage.
fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu {
let menu = gtk::gio::Menu::new();
// Section 1: Launch
let section1 = gtk::gio::Menu::new();
section1.append(Some("Launch"), Some(&format!("win.launch-appimage(int64 {})", record.id)));
menu.append_section(None, &section1);
// Section 2: Actions
let section2 = gtk::gio::Menu::new();
section2.append(Some("Check for Updates"), Some(&format!("win.check-update(int64 {})", record.id)));
section2.append(Some("Scan for Vulnerabilities"), Some(&format!("win.scan-security(int64 {})", record.id)));
menu.append_section(None, &section2);
// Section 3: Integration + folder
let section3 = gtk::gio::Menu::new();
let integrate_label = if record.integrated { "Remove Integration" } else { "Integrate" };
section3.append(Some(integrate_label), Some(&format!("win.toggle-integration(int64 {})", record.id)));
section3.append(Some("Open Containing Folder"), Some(&format!("win.open-folder(int64 {})", record.id)));
menu.append_section(None, &section3);
// Section 4: Clipboard
let section4 = gtk::gio::Menu::new();
section4.append(Some("Copy Path"), Some(&format!("win.copy-path(int64 {})", record.id)));
menu.append_section(None, &section4);
menu
}
/// Attach a right-click context menu to a widget.
fn attach_context_menu(widget: &impl gtk::prelude::IsA<gtk::Widget>, menu_model: &gtk::gio::Menu) {
let popover = gtk::PopoverMenu::from_model(Some(menu_model));
popover.set_parent(widget.as_ref());
popover.set_has_arrow(false);
// Right-click
let click = gtk::GestureClick::new();
click.set_button(3);
let popover_ref = popover.clone();
click.connect_pressed(move |gesture, _, x, y| {
gesture.set_state(gtk::EventSequenceState::Claimed);
popover_ref.set_pointing_to(Some(&gtk::gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
popover_ref.popup();
});
widget.as_ref().add_controller(click);
// Long press for touch
let long_press = gtk::GestureLongPress::new();
let popover_ref = popover;
long_press.connect_pressed(move |gesture, x, y| {
gesture.set_state(gtk::EventSequenceState::Claimed);
popover_ref.set_pointing_to(Some(&gtk::gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
popover_ref.popup();
});
widget.as_ref().add_controller(long_press);
}