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

@@ -22,40 +22,51 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
// Toast overlay for copy actions
let toast_overlay = adw::ToastOverlay::new();
// Scrollable content with clamp
let clamp = adw::Clamp::builder()
.maximum_size(800)
.tightening_threshold(600)
.build();
// Main content container
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(24)
.margin_top(24)
.margin_bottom(24)
.margin_start(18)
.margin_end(18)
.build();
// Banner: App identity (not a boxed group)
content.append(&build_banner(record));
// Hero banner (always visible at top)
let banner = build_banner(record);
content.append(&banner);
// Group 1: System Integration (Desktop Integration + Runtime Compatibility + Sandboxing)
content.append(&build_system_integration_group(record, db));
// ViewSwitcher (tab bar) - inline style, between banner and tab content
let view_stack = adw::ViewStack::new();
// Group 2: Updates & Usage
content.append(&build_updates_usage_group(record, db));
let switcher = adw::ViewSwitcher::builder()
.stack(&view_stack)
.policy(adw::ViewSwitcherPolicy::Wide)
.build();
switcher.add_css_class("inline");
switcher.add_css_class("detail-view-switcher");
content.append(&switcher);
// Group 3: Security & Storage
content.append(&build_security_storage_group(record, db, &toast_overlay));
// Build tab pages
let overview_page = build_overview_tab(record, db);
view_stack.add_titled(&overview_page, Some("overview"), "Overview");
view_stack.page(&overview_page).set_icon_name(Some("info-symbolic"));
clamp.set_child(Some(&content));
let system_page = build_system_tab(record, db);
view_stack.add_titled(&system_page, Some("system"), "System");
view_stack.page(&system_page).set_icon_name(Some("system-run-symbolic"));
let security_page = build_security_tab(record, db);
view_stack.add_titled(&security_page, Some("security"), "Security");
view_stack.page(&security_page).set_icon_name(Some("security-medium-symbolic"));
let storage_page = build_storage_tab(record, db, &toast_overlay);
view_stack.add_titled(&storage_page, Some("storage"), "Storage");
view_stack.page(&storage_page).set_icon_name(Some("drive-harddisk-symbolic"));
// Scrollable area for tab content
let scrolled = gtk::ScrolledWindow::builder()
.child(&clamp)
.child(&view_stack)
.vexpand(true)
.build();
content.append(&scrolled);
toast_overlay.set_child(Some(&scrolled));
toast_overlay.set_child(Some(&content));
// Header bar with per-app actions
let header = adw::HeaderBar::new();
@@ -108,7 +119,6 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
analysis.has_x11_connection,
analysis.env_vars.len(),
);
// Store the runtime analysis result in the database
db_wayland.update_runtime_wayland_status(
record_id, status_str,
).ok();
@@ -160,15 +170,18 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box {
let banner = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(16)
.margin_start(18)
.margin_end(18)
.build();
banner.add_css_class("detail-banner");
banner.set_accessible_role(gtk::AccessibleRole::Banner);
let name = record.app_name.as_deref().unwrap_or(&record.filename);
// Large icon (64x64)
let icon = widgets::app_icon(record.icon_path.as_deref(), name, 64);
// Large icon (96x96) with drop shadow
let icon = widgets::app_icon(record.icon_path.as_deref(), name, 96);
icon.set_valign(gtk::Align::Start);
icon.add_css_class("icon-dropshadow");
banner.append(&icon);
// Text column
@@ -246,14 +259,194 @@ fn build_banner(record: &AppImageRecord) -> gtk::Box {
banner
}
/// Group 1: System Integration (Desktop Integration + Runtime + Sandboxing)
fn build_system_integration_group(record: &AppImageRecord, db: &Rc<Database>) -> adw::PreferencesGroup {
let group = adw::PreferencesGroup::builder()
.title("System Integration")
.description("Desktop integration, runtime compatibility, and sandboxing")
/// Tab 1: Overview - most commonly needed info at a glance
fn build_overview_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let tab = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(24)
.margin_top(18)
.margin_bottom(24)
.margin_start(18)
.margin_end(18)
.build();
let clamp = adw::Clamp::builder()
.maximum_size(800)
.tightening_threshold(600)
.build();
let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(24)
.build();
// Updates section
let updates_group = adw::PreferencesGroup::builder()
.title("Updates")
.build();
if let Some(ref update_type) = record.update_type {
let display_label = updater::parse_update_info(update_type)
.map(|ut| ut.type_label_display())
.unwrap_or("Unknown format");
let row = adw::ActionRow::builder()
.title("Update method")
.subtitle(display_label)
.build();
updates_group.add(&row);
} else {
let row = adw::ActionRow::builder()
.title("Update method")
.subtitle("This app cannot check for updates automatically")
.build();
let badge = widgets::status_badge("None", "neutral");
badge.set_valign(gtk::Align::Center);
row.add_suffix(&badge);
updates_group.add(&row);
}
if let Some(ref latest) = record.latest_version {
let is_newer = record
.app_version
.as_deref()
.map(|current| crate::core::updater::version_is_newer(latest, current))
.unwrap_or(true);
if is_newer {
let subtitle = format!(
"{} -> {}",
record.app_version.as_deref().unwrap_or("unknown"),
latest
);
let row = adw::ActionRow::builder()
.title("Update available")
.subtitle(&subtitle)
.build();
let badge = widgets::status_badge("Update", "info");
badge.set_valign(gtk::Align::Center);
row.add_suffix(&badge);
updates_group.add(&row);
} else {
let row = adw::ActionRow::builder()
.title("Status")
.subtitle("Up to date")
.build();
let badge = widgets::status_badge("Latest", "success");
badge.set_valign(gtk::Align::Center);
row.add_suffix(&badge);
updates_group.add(&row);
}
}
if let Some(ref checked) = record.update_checked {
let row = adw::ActionRow::builder()
.title("Last checked")
.subtitle(checked)
.build();
updates_group.add(&row);
}
inner.append(&updates_group);
// Usage section
let usage_group = adw::PreferencesGroup::builder()
.title("Usage")
.build();
let stats = launcher::get_launch_stats(db, record.id);
let launches_row = adw::ActionRow::builder()
.title("Total launches")
.subtitle(&stats.total_launches.to_string())
.build();
usage_group.add(&launches_row);
if let Some(ref last) = stats.last_launched {
let row = adw::ActionRow::builder()
.title("Last launched")
.subtitle(last)
.build();
usage_group.add(&row);
}
inner.append(&usage_group);
// File info section
let info_group = adw::PreferencesGroup::builder()
.title("File Information")
.build();
let type_str = match record.appimage_type {
Some(1) => "Type 1",
Some(2) => "Type 2",
_ => "Unknown",
};
let type_row = adw::ActionRow::builder()
.title("AppImage type")
.subtitle(type_str)
.tooltip_text("Type 1 uses ISO9660, Type 2 uses SquashFS")
.build();
info_group.add(&type_row);
let exec_row = adw::ActionRow::builder()
.title("Executable")
.subtitle(if record.is_executable { "Yes" } else { "No" })
.build();
info_group.add(&exec_row);
let seen_row = adw::ActionRow::builder()
.title("First seen")
.subtitle(&record.first_seen)
.build();
info_group.add(&seen_row);
let scanned_row = adw::ActionRow::builder()
.title("Last scanned")
.subtitle(&record.last_scanned)
.build();
info_group.add(&scanned_row);
if let Some(ref notes) = record.notes {
if !notes.is_empty() {
let row = adw::ActionRow::builder()
.title("Notes")
.subtitle(notes)
.build();
info_group.add(&row);
}
}
inner.append(&info_group);
clamp.set_child(Some(&inner));
tab.append(&clamp);
tab
}
/// Tab 2: System - integration, compatibility, sandboxing
fn build_system_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let tab = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(24)
.margin_top(18)
.margin_bottom(24)
.margin_start(18)
.margin_end(18)
.build();
let clamp = adw::Clamp::builder()
.maximum_size(800)
.tightening_threshold(600)
.build();
let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(24)
.build();
// Desktop Integration group
let integration_group = adw::PreferencesGroup::builder()
.title("Desktop Integration")
.description("Add this app to your application menu")
.build();
// --- Desktop Integration ---
let switch_row = adw::SwitchRow::builder()
.title("Add to application menu")
.subtitle("Creates a .desktop file and installs the icon")
@@ -291,9 +484,8 @@ fn build_system_integration_group(record: &AppImageRecord, db: &Rc<Database>) ->
db_ref.set_integrated(record_id, false, None).ok();
}
});
group.add(&switch_row);
integration_group.add(&switch_row);
// Desktop file path if integrated
if record.integrated {
if let Some(ref desktop_file) = record.desktop_file {
let row = adw::ActionRow::builder()
@@ -301,12 +493,18 @@ fn build_system_integration_group(record: &AppImageRecord, db: &Rc<Database>) ->
.subtitle(desktop_file)
.subtitle_selectable(true)
.build();
row.add_css_class("monospace");
group.add(&row);
row.add_css_class("property");
integration_group.add(&row);
}
}
inner.append(&integration_group);
// Runtime Compatibility group
let compat_group = adw::PreferencesGroup::builder()
.title("Runtime Compatibility")
.description("Wayland support and FUSE status")
.build();
// --- Runtime Compatibility ---
let wayland_status = record
.wayland_status
.as_deref()
@@ -321,9 +519,9 @@ fn build_system_integration_group(record: &AppImageRecord, db: &Rc<Database>) ->
let wayland_badge = widgets::status_badge(wayland_status.label(), wayland_status.badge_class());
wayland_badge.set_valign(gtk::Align::Center);
wayland_row.add_suffix(&wayland_badge);
group.add(&wayland_row);
compat_group.add(&wayland_row);
// Wayland analyze button - runs toolkit detection on demand
// Wayland analyze button
let analyze_row = adw::ActionRow::builder()
.title("Analyze toolkit")
.subtitle("Inspect bundled libraries to detect UI toolkit")
@@ -364,7 +562,7 @@ fn build_system_integration_group(record: &AppImageRecord, db: &Rc<Database>) ->
}
});
});
group.add(&analyze_row);
compat_group.add(&analyze_row);
// Runtime Wayland status (from post-launch analysis)
if let Some(ref runtime_status) = record.runtime_wayland_status {
@@ -380,7 +578,7 @@ fn build_system_integration_group(record: &AppImageRecord, db: &Rc<Database>) ->
.build();
runtime_row.add_suffix(&info);
}
group.add(&runtime_row);
compat_group.add(&runtime_row);
}
let fuse_system = fuse::detect_system_fuse();
@@ -402,7 +600,7 @@ fn build_system_integration_group(record: &AppImageRecord, db: &Rc<Database>) ->
);
fuse_badge.set_valign(gtk::Align::Center);
fuse_row.add_suffix(&fuse_badge);
group.add(&fuse_row);
compat_group.add(&fuse_row);
// Per-app FUSE launch method
let appimage_path = std::path::Path::new(&record.path);
@@ -417,9 +615,15 @@ fn build_system_integration_group(record: &AppImageRecord, db: &Rc<Database>) ->
);
launch_badge.set_valign(gtk::Align::Center);
launch_method_row.add_suffix(&launch_badge);
group.add(&launch_method_row);
compat_group.add(&launch_method_row);
inner.append(&compat_group);
// Sandboxing group
let sandbox_group = adw::PreferencesGroup::builder()
.title("Sandboxing")
.description("Isolate this app with Firejail")
.build();
// --- Sandboxing ---
let current_mode = record
.sandbox_mode
.as_deref()
@@ -454,7 +658,7 @@ fn build_system_integration_group(record: &AppImageRecord, db: &Rc<Database>) ->
log::warn!("Failed to update sandbox mode: {}", e);
}
});
group.add(&firejail_row);
sandbox_group.add(&firejail_row);
if !firejail_available {
let info_row = adw::ActionRow::builder()
@@ -464,133 +668,41 @@ fn build_system_integration_group(record: &AppImageRecord, db: &Rc<Database>) ->
let badge = widgets::status_badge("Missing", "warning");
badge.set_valign(gtk::Align::Center);
info_row.add_suffix(&badge);
group.add(&info_row);
sandbox_group.add(&info_row);
}
inner.append(&sandbox_group);
group
clamp.set_child(Some(&inner));
tab.append(&clamp);
tab
}
fn wayland_description(status: &WaylandStatus) -> &'static str {
match status {
WaylandStatus::Native => "Runs natively on Wayland",
WaylandStatus::XWayland => "Runs via XWayland compatibility layer",
WaylandStatus::Possible => "May run on Wayland with additional flags",
WaylandStatus::X11Only => "X11 only - no Wayland support",
WaylandStatus::Unknown => "Could not determine Wayland compatibility",
}
}
/// Tab 3: Security - vulnerability scanning and integrity
fn build_security_tab(record: &AppImageRecord, db: &Rc<Database>) -> gtk::Box {
let tab = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(24)
.margin_top(18)
.margin_bottom(24)
.margin_start(18)
.margin_end(18)
.build();
fn fuse_description(status: &FuseStatus) -> &'static str {
match status {
FuseStatus::FullyFunctional => "FUSE mount available - native AppImage launch",
FuseStatus::Fuse3Only => "Only FUSE3 installed - may need libfuse2",
FuseStatus::NoFusermount => "fusermount binary not found",
FuseStatus::NoDevFuse => "/dev/fuse device not available",
FuseStatus::MissingLibfuse2 => "libfuse2 not installed",
}
}
let clamp = adw::Clamp::builder()
.maximum_size(800)
.tightening_threshold(600)
.build();
let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(24)
.build();
/// Group 2: Updates & Usage
fn build_updates_usage_group(record: &AppImageRecord, db: &Rc<Database>) -> adw::PreferencesGroup {
let group = adw::PreferencesGroup::builder()
.title("Updates & Usage")
.description("Update status and launch statistics")
.title("Vulnerability Scanning")
.description("Check bundled libraries for known CVEs")
.build();
// --- Updates ---
if let Some(ref update_type) = record.update_type {
let display_label = updater::parse_update_info(update_type)
.map(|ut| ut.type_label_display())
.unwrap_or("Unknown format");
let row = adw::ActionRow::builder()
.title("Update method")
.subtitle(display_label)
.build();
group.add(&row);
} else {
let row = adw::ActionRow::builder()
.title("Update method")
.subtitle("This app cannot check for updates automatically")
.build();
let badge = widgets::status_badge("None", "neutral");
badge.set_valign(gtk::Align::Center);
row.add_suffix(&badge);
group.add(&row);
}
if let Some(ref latest) = record.latest_version {
let is_newer = record
.app_version
.as_deref()
.map(|current| crate::core::updater::version_is_newer(latest, current))
.unwrap_or(true);
if is_newer {
let subtitle = format!(
"{} -> {}",
record.app_version.as_deref().unwrap_or("unknown"),
latest
);
let row = adw::ActionRow::builder()
.title("Update available")
.subtitle(&subtitle)
.build();
let badge = widgets::status_badge("Update", "info");
badge.set_valign(gtk::Align::Center);
row.add_suffix(&badge);
group.add(&row);
} else {
let row = adw::ActionRow::builder()
.title("Status")
.subtitle("Up to date")
.build();
let badge = widgets::status_badge("Latest", "success");
badge.set_valign(gtk::Align::Center);
row.add_suffix(&badge);
group.add(&row);
}
}
if let Some(ref checked) = record.update_checked {
let row = adw::ActionRow::builder()
.title("Last checked")
.subtitle(checked)
.build();
group.add(&row);
}
// --- Usage Statistics ---
let stats = launcher::get_launch_stats(db, record.id);
let launches_row = adw::ActionRow::builder()
.title("Total launches")
.subtitle(&stats.total_launches.to_string())
.build();
group.add(&launches_row);
if let Some(ref last) = stats.last_launched {
let row = adw::ActionRow::builder()
.title("Last launched")
.subtitle(last)
.build();
group.add(&row);
}
group
}
/// Group 3: Security & Storage (Security + Disk Footprint + File Details)
fn build_security_storage_group(
record: &AppImageRecord,
db: &Rc<Database>,
toast_overlay: &adw::ToastOverlay,
) -> adw::PreferencesGroup {
let group = adw::PreferencesGroup::builder()
.title("Security & Storage")
.description("Vulnerability scanning, disk footprint, and file details")
.build();
// --- Security ---
let libs = db.get_bundled_libraries(record.id).unwrap_or_default();
let summary = db.get_cve_summary(record.id).unwrap_or_default();
@@ -677,15 +789,69 @@ fn build_security_storage_group(
});
});
group.add(&scan_row);
inner.append(&group);
// Integrity group
if record.sha256.is_some() {
let integrity_group = adw::PreferencesGroup::builder()
.title("Integrity")
.build();
if let Some(ref hash) = record.sha256 {
let hash_row = adw::ActionRow::builder()
.title("SHA256 checksum")
.subtitle(hash)
.subtitle_selectable(true)
.tooltip_text("Cryptographic hash for verifying file integrity")
.build();
hash_row.add_css_class("property");
integrity_group.add(&hash_row);
}
inner.append(&integrity_group);
}
clamp.set_child(Some(&inner));
tab.append(&clamp);
tab
}
/// Tab 4: Storage - disk usage and data discovery
fn build_storage_tab(
record: &AppImageRecord,
db: &Rc<Database>,
toast_overlay: &adw::ToastOverlay,
) -> gtk::Box {
let tab = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(24)
.margin_top(18)
.margin_bottom(24)
.margin_start(18)
.margin_end(18)
.build();
let clamp = adw::Clamp::builder()
.maximum_size(800)
.tightening_threshold(600)
.build();
let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(24)
.build();
// Disk usage group
let size_group = adw::PreferencesGroup::builder()
.title("Disk Usage")
.build();
// --- Disk Footprint ---
let fp = footprint::get_footprint(db, record.id, record.size_bytes as u64);
let appimage_row = adw::ActionRow::builder()
.title("AppImage file size")
.subtitle(&widgets::format_size(record.size_bytes))
.build();
group.add(&appimage_row);
size_group.add(&appimage_row);
if !fp.paths.is_empty() {
let data_total = fp.data_total();
@@ -699,9 +865,16 @@ fn build_security_storage_group(
widgets::format_size(fp.total_size() as i64),
))
.build();
group.add(&total_row);
size_group.add(&total_row);
}
}
inner.append(&size_group);
// Data paths group
let paths_group = adw::PreferencesGroup::builder()
.title("Data Paths")
.description("Config, data, and cache directories for this app")
.build();
// Discover button
let discover_row = adw::ActionRow::builder()
@@ -749,7 +922,7 @@ fn build_security_storage_group(
}
});
});
group.add(&discover_row);
paths_group.add(&discover_row);
// Individual discovered paths with type icons and confidence badges
for dp in &fp.paths {
@@ -774,17 +947,22 @@ fn build_security_storage_group(
.valign(gtk::Align::Center)
.build();
row.add_suffix(&size_label);
group.add(&row);
paths_group.add(&row);
}
}
inner.append(&paths_group);
// File location group
let location_group = adw::PreferencesGroup::builder()
.title("File Location")
.build();
// --- File Details ---
// Path with copy button
let path_row = adw::ActionRow::builder()
.title("Path")
.subtitle(&record.path)
.subtitle_selectable(true)
.build();
path_row.add_css_class("property");
let copy_path_btn = widgets::copy_button(&record.path, Some(toast_overlay));
copy_path_btn.set_valign(gtk::Align::Center);
path_row.add_suffix(&copy_path_btn);
@@ -812,67 +990,30 @@ fn build_security_storage_group(
});
path_row.add_suffix(&open_folder_btn);
}
group.add(&path_row);
location_group.add(&path_row);
inner.append(&location_group);
// Type
let type_str = match record.appimage_type {
Some(1) => "Type 1",
Some(2) => "Type 2",
_ => "Unknown",
};
let type_row = adw::ActionRow::builder()
.title("AppImage type")
.subtitle(type_str)
.tooltip_text("Type 1 uses ISO9660, Type 2 uses SquashFS")
.build();
group.add(&type_row);
// Executable
let exec_row = adw::ActionRow::builder()
.title("Executable")
.subtitle(if record.is_executable { "Yes" } else { "No" })
.build();
group.add(&exec_row);
// SHA256 with copy button
if let Some(ref hash) = record.sha256 {
let hash_row = adw::ActionRow::builder()
.title("SHA256 checksum")
.subtitle(hash)
.subtitle_selectable(true)
.tooltip_text("Cryptographic hash for verifying file integrity")
.build();
hash_row.add_css_class("monospace");
let copy_hash_btn = widgets::copy_button(hash, Some(toast_overlay));
copy_hash_btn.set_valign(gtk::Align::Center);
hash_row.add_suffix(&copy_hash_btn);
group.add(&hash_row);
}
// First seen
let seen_row = adw::ActionRow::builder()
.title("First seen")
.subtitle(&record.first_seen)
.build();
group.add(&seen_row);
// Last scanned
let scanned_row = adw::ActionRow::builder()
.title("Last scanned")
.subtitle(&record.last_scanned)
.build();
group.add(&scanned_row);
// Notes
if let Some(ref notes) = record.notes {
if !notes.is_empty() {
let row = adw::ActionRow::builder()
.title("Notes")
.subtitle(notes)
.build();
group.add(&row);
}
}
group
clamp.set_child(Some(&inner));
tab.append(&clamp);
tab
}
fn wayland_description(status: &WaylandStatus) -> &'static str {
match status {
WaylandStatus::Native => "Runs natively on Wayland",
WaylandStatus::XWayland => "Runs via XWayland compatibility layer",
WaylandStatus::Possible => "May run on Wayland with additional flags",
WaylandStatus::X11Only => "X11 only - no Wayland support",
WaylandStatus::Unknown => "Could not determine Wayland compatibility",
}
}
fn fuse_description(status: &FuseStatus) -> &'static str {
match status {
FuseStatus::FullyFunctional => "FUSE mount available - native AppImage launch",
FuseStatus::Fuse3Only => "Only FUSE3 installed - may need libfuse2",
FuseStatus::NoFusermount => "fusermount binary not found",
FuseStatus::NoDevFuse => "/dev/fuse device not available",
FuseStatus::MissingLibfuse2 => "libfuse2 not installed",
}
}