From b0852f781ce69370354514ef75908338f0d152c4 Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 27 Feb 2026 11:10:23 +0200 Subject: [PATCH] Implement UI/UX overhaul - cards, list, tabbed detail, context menu --- data/resources/style.css | 58 ++-- src/ui/app_card.rs | 123 ++++---- src/ui/detail_view.rs | 603 ++++++++++++++++++++++++--------------- src/ui/library_view.rs | 173 ++++++----- src/ui/widgets.rs | 1 + src/window.rs | 184 ++++++++++++ 6 files changed, 745 insertions(+), 397 deletions(-) diff --git a/data/resources/style.css b/data/resources/style.css index 1fa9b02..966e2ad 100644 --- a/data/resources/style.css +++ b/data/resources/style.css @@ -66,30 +66,18 @@ color: @window_fg_color; } -/* ===== App Cards (Grid View) ===== */ -.app-card { - padding: 14px; - border-radius: 14px; - background: @card_bg_color; - border: 1px solid alpha(@window_fg_color, 0.08); - transition: all 150ms ease; -} - -.app-card:hover { - background: mix(@card_bg_color, @window_fg_color, 0.04); - box-shadow: 0 2px 8px alpha(black, 0.06); -} - -.app-card:active { - background: mix(@card_bg_color, @window_fg_color, 0.08); -} - -/* Focus indicator for grid cards */ -flowboxchild:focus-visible .app-card { +/* ===== Card View (using libadwaita .card) ===== */ +flowboxchild:focus-visible .card { outline: 2px solid @accent_bg_color; outline-offset: 3px; } +/* Rounded icon clipping for list view */ +.icon-rounded { + border-radius: 8px; + overflow: hidden; +} + /* ===== WCAG AAA Focus Indicators ===== */ button:focus-visible, togglebutton:focus-visible, @@ -132,7 +120,20 @@ row:focus-visible { /* ===== Detail View Banner ===== */ .detail-banner { - padding: 12px 0; + padding: 18px 0; + background-image: linear-gradient( + to bottom, + alpha(@accent_bg_color, 0.08), + transparent + ); + border-radius: 12px; + margin-bottom: 6px; +} + +/* Inline ViewSwitcher positioning */ +.detail-view-switcher { + margin-top: 6px; + margin-bottom: 6px; } /* ===== Quick Action Pills ===== */ @@ -151,15 +152,6 @@ row:focus-visible { /* ===== Dark Mode Differentiation ===== */ @media (prefers-color-scheme: dark) { - .app-card { - border: 1px solid alpha(@window_fg_color, 0.12); - } - - .app-card:hover { - box-shadow: 0 2px 8px alpha(black, 0.15); - background: mix(@card_bg_color, @window_fg_color, 0.06); - } - .compat-warning-banner { background: alpha(@warning_bg_color, 0.1); border: 1px solid alpha(@warning_bg_color, 0.2); @@ -168,11 +160,7 @@ row:focus-visible { /* ===== High Contrast Mode (WCAG AAA 1.4.6) ===== */ @media (prefers-contrast: more) { - .app-card { - border: 2px solid @window_fg_color; - } - - flowboxchild:focus-visible .app-card { + flowboxchild:focus-visible .card { outline-width: 3px; } diff --git a/src/ui/app_card.rs b/src/ui/app_card.rs index f91e634..6374b31 100644 --- a/src/ui/app_card.rs +++ b/src/ui/app_card.rs @@ -11,22 +11,23 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild { let card = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(6) - .margin_top(12) - .margin_bottom(12) - .margin_start(12) - .margin_end(12) + .margin_top(14) + .margin_bottom(14) + .margin_start(14) + .margin_end(14) .halign(gtk::Align::Center) .build(); - card.add_css_class("app-card"); - card.set_size_request(180, -1); + card.add_css_class("card"); + card.set_size_request(200, -1); - // Icon (64x64) with integration emblem overlay + // Icon (72x72) with integration emblem overlay let name = record.app_name.as_deref().unwrap_or(&record.filename); let icon_widget = widgets::app_icon( record.icon_path.as_deref(), name, - 64, + 72, ); + icon_widget.add_css_class("icon-dropshadow"); // If integrated, overlay a small checkmark emblem if record.integrated { @@ -46,75 +47,46 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild { card.append(&icon_widget); } - // App name - use libadwaita built-in heading class + // App name - .title-3 for more visual weight let name_label = gtk::Label::builder() .label(name) - .css_classes(["heading"]) + .css_classes(["title-3"]) .ellipsize(gtk::pango::EllipsizeMode::End) .max_width_chars(20) .build(); - // Version - use libadwaita built-in caption + dimmed + // Version + size combined on one line let version_text = record.app_version.as_deref().unwrap_or(""); - let version_label = gtk::Label::builder() - .label(version_text) - .css_classes(["caption", "dimmed"]) + let size_text = widgets::format_size(record.size_bytes); + let meta_text = if version_text.is_empty() { + size_text + } else { + format!("{} - {}", version_text, size_text) + }; + let meta_label = gtk::Label::builder() + .label(&meta_text) + .css_classes(["caption", "dimmed", "numeric"]) .ellipsize(gtk::pango::EllipsizeMode::End) .build(); - // File size as subtle caption - let size_text = widgets::format_size(record.size_bytes); - let size_label = gtk::Label::builder() - .label(&size_text) - .css_classes(["caption", "dimmed"]) - .build(); - card.append(&name_label); - if !version_text.is_empty() { - card.append(&version_label); + card.append(&meta_label); + + // Single most important badge (priority: Update > FUSE issue > Wayland issue) + if let Some(badge) = build_priority_badge(record) { + let badge_box = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .halign(gtk::Align::Center) + .margin_top(4) + .build(); + badge_box.append(&badge); + card.append(&badge_box); } - card.append(&size_label); - - // Status badges row - let badges = gtk::Box::builder() - .orientation(gtk::Orientation::Horizontal) - .spacing(4) - .halign(gtk::Align::Center) - .build(); - badges.add_css_class("badge-row"); - - // Wayland status badge - if let Some(ref ws) = record.wayland_status { - let status = WaylandStatus::from_str(ws); - if status != WaylandStatus::Unknown { - badges.append(&widgets::status_badge(status.label(), status.badge_class())); - } - } - - // FUSE status badge - if let Some(ref fs) = record.fuse_status { - let status = FuseStatus::from_str(fs); - if !status.is_functional() { - badges.append(&widgets::status_badge(status.label(), status.badge_class())); - } - } - - // Update available badge - if record.latest_version.is_some() { - if let (Some(ref latest), Some(ref current)) = - (&record.latest_version, &record.app_version) - { - if crate::core::updater::version_is_newer(latest, current) { - badges.append(&widgets::status_badge("Update", "info")); - } - } - } - - card.append(&badges); let child = gtk::FlowBoxChild::builder() .child(&card) .build(); + child.add_css_class("activatable"); // Accessible label for screen readers let accessible_name = build_accessible_label(record); @@ -123,6 +95,35 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild { child } +/// Return the single most important badge for a card. +/// Priority: Update available > FUSE issue > Wayland issue. +pub fn build_priority_badge(record: &AppImageRecord) -> Option { + // 1. Update available (highest priority) + if let (Some(ref latest), Some(ref current)) = (&record.latest_version, &record.app_version) { + if crate::core::updater::version_is_newer(latest, current) { + return Some(widgets::status_badge("Update", "info")); + } + } + + // 2. FUSE issue + if let Some(ref fs) = record.fuse_status { + let status = FuseStatus::from_str(fs); + if !status.is_functional() { + return Some(widgets::status_badge(status.label(), status.badge_class())); + } + } + + // 3. Wayland issue (not Native or Unknown) + if let Some(ref ws) = record.wayland_status { + let status = WaylandStatus::from_str(ws); + if status != WaylandStatus::Unknown && status != WaylandStatus::Native { + return Some(widgets::status_badge(status.label(), status.badge_class())); + } + } + + None +} + /// Build a descriptive accessible label for screen readers. fn build_accessible_label(record: &AppImageRecord) -> String { let name = record.app_name.as_deref().unwrap_or(&record.filename); diff --git a/src/ui/detail_view.rs b/src/ui/detail_view.rs index a9b4c77..4f639b0 100644 --- a/src/ui/detail_view.rs +++ b/src/ui/detail_view.rs @@ -22,40 +22,51 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc) -> 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) -> 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) -> 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) -> 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) -> 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) -> 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) -> .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) -> 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) -> } }); }); - 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) -> .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) -> ); 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) -> ); 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) -> 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) -> 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) -> 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) -> 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, - 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, + 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(©_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(©_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", + } } diff --git a/src/ui/library_view.rs b/src/ui/library_view.rs index 7175be9..2110111 100644 --- a/src/ui/library_view.rs +++ b/src/ui/library_view.rs @@ -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, §ion1); + + // 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, §ion2); + + // 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, §ion3); + + // 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, §ion4); + + menu +} + +/// Attach a right-click context menu to a widget. +fn attach_context_menu(widget: &impl gtk::prelude::IsA, menu_model: >k::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(>k::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(>k::gdk::Rectangle::new(x as i32, y as i32, 1, 1))); + popover_ref.popup(); + }); + widget.as_ref().add_controller(long_press); +} diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index d3bc9f8..821b48d 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -33,6 +33,7 @@ pub fn status_badge_with_icon(icon_name: &str, text: &str, style_class: &str) -> } /// Create a badge showing integration status. +#[allow(dead_code)] pub fn integration_badge(integrated: bool) -> gtk::Label { if integrated { status_badge("Integrated", "success") diff --git a/src/window.rs b/src/window.rs index 09d2475..1a0709d 100644 --- a/src/window.rs +++ b/src/window.rs @@ -10,7 +10,11 @@ use crate::core::database::Database; use crate::core::discovery; use crate::core::fuse; use crate::core::inspector; +use crate::core::integrator; +use crate::core::launcher; use crate::core::orphan; +use crate::core::security; +use crate::core::updater; use crate::core::wayland; use crate::i18n::{i18n, ni18n_f}; use crate::ui::cleanup_wizard; @@ -371,6 +375,186 @@ impl DriftwoodWindow { shortcuts_action, ]); + // --- Context menu actions (parameterized with record ID) --- + let param_type = Some(glib::VariantTy::INT64); + + // Launch action + let launch_action = gio::SimpleAction::new("launch-appimage", param_type); + { + let window_weak = self.downgrade(); + launch_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let db = window.database().clone(); + if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { + let appimage_path = std::path::Path::new(&record.path); + match launcher::launch_appimage(&db, record_id, appimage_path, "gui_context", &[], &[]) { + launcher::LaunchResult::Started { child, method } => { + log::info!("Context menu launched: {} (PID: {}, method: {})", record.path, child.id(), method.as_str()); + } + launcher::LaunchResult::Failed(msg) => { + log::error!("Failed to launch: {}", msg); + } + } + } + }); + } + self.add_action(&launch_action); + + // Check for updates action (per-app) + let check_update_action = gio::SimpleAction::new("check-update", param_type); + { + let window_weak = self.downgrade(); + check_update_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(move || { + let bg_db = Database::open().expect("DB open failed"); + if let Ok(Some(record)) = bg_db.get_appimage_by_id(record_id) { + let appimage_path = std::path::Path::new(&record.path); + if !appimage_path.exists() { + return false; + } + let (_type_label, raw_info, check_result) = updater::check_appimage_for_update( + appimage_path, + record.app_version.as_deref(), + ); + if raw_info.is_some() { + bg_db.update_update_info(record_id, raw_info.as_deref(), None).ok(); + } + if let Some(result) = check_result { + if result.update_available { + if let Some(ref version) = result.latest_version { + bg_db.set_update_available(record_id, Some(version), result.download_url.as_deref()).ok(); + return true; + } + } else { + bg_db.clear_update_available(record_id).ok(); + } + } + } + false + }).await; + match result { + Ok(true) => toast_overlay.add_toast(adw::Toast::new("Update available!")), + Ok(false) => toast_overlay.add_toast(adw::Toast::new("Already up to date")), + Err(_) => toast_overlay.add_toast(adw::Toast::new("Update check failed")), + } + }); + }); + } + self.add_action(&check_update_action); + + // Scan for vulnerabilities (per-app) + let scan_security_action = gio::SimpleAction::new("scan-security", param_type); + { + let window_weak = self.downgrade(); + scan_security_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); + glib::spawn_future_local(async move { + let result = gio::spawn_blocking(move || { + let bg_db = Database::open().expect("DB open failed"); + if let Ok(Some(record)) = bg_db.get_appimage_by_id(record_id) { + let appimage_path = std::path::Path::new(&record.path); + let scan_result = security::scan_and_store(&bg_db, record_id, appimage_path); + return Some(scan_result.total_cves()); + } + None + }).await; + match result { + Ok(Some(total)) => { + if total == 0 { + toast_overlay.add_toast(adw::Toast::new("No vulnerabilities found")); + } else { + let msg = format!("Found {} CVE{}", total, if total == 1 { "" } else { "s" }); + toast_overlay.add_toast(adw::Toast::new(&msg)); + } + } + _ => toast_overlay.add_toast(adw::Toast::new("Security scan failed")), + } + }); + }); + } + self.add_action(&scan_security_action); + + // Toggle integration + let toggle_integration_action = gio::SimpleAction::new("toggle-integration", param_type); + { + let window_weak = self.downgrade(); + toggle_integration_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let db = window.database().clone(); + let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); + if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { + if record.integrated { + integrator::remove_integration(&record).ok(); + db.set_integrated(record_id, false, None).ok(); + toast_overlay.add_toast(adw::Toast::new("Integration removed")); + } else { + match integrator::integrate(&record) { + Ok(result) => { + let desktop_path = result.desktop_file_path.to_string_lossy().to_string(); + db.set_integrated(record_id, true, Some(&desktop_path)).ok(); + toast_overlay.add_toast(adw::Toast::new("Integrated into desktop menu")); + } + Err(e) => { + log::error!("Integration failed: {}", e); + toast_overlay.add_toast(adw::Toast::new("Integration failed")); + } + } + } + // Refresh library view + let lib_view = window.imp().library_view.get().unwrap(); + match db.get_all_appimages() { + Ok(records) => lib_view.populate(records), + Err(_) => {} + } + } + }); + } + self.add_action(&toggle_integration_action); + + // Open containing folder + let open_folder_action = gio::SimpleAction::new("open-folder", param_type); + { + let window_weak = self.downgrade(); + open_folder_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let db = window.database().clone(); + if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { + let file = gio::File::for_path(&record.path); + let file_launcher = gtk::FileLauncher::new(Some(&file)); + file_launcher.open_containing_folder(gtk::Window::NONE, None::<&gio::Cancellable>, |_| {}); + } + }); + } + self.add_action(&open_folder_action); + + // Copy path to clipboard + let copy_path_action = gio::SimpleAction::new("copy-path", param_type); + { + let window_weak = self.downgrade(); + copy_path_action.connect_activate(move |_, param| { + let Some(window) = window_weak.upgrade() else { return }; + let Some(record_id) = param.and_then(|p| p.get::()) else { return }; + let db = window.database().clone(); + let toast_overlay = window.imp().toast_overlay.get().unwrap().clone(); + if let Ok(Some(record)) = db.get_appimage_by_id(record_id) { + let display = gtk::prelude::WidgetExt::display(&window); + let clipboard = display.clipboard(); + clipboard.set_text(&record.path); + toast_overlay.add_toast(adw::Toast::new("Path copied to clipboard")); + } + }); + } + self.add_action(©_path_action); + // Keyboard shortcuts if let Some(app) = self.application() { let gtk_app = app.downcast_ref::().unwrap();