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:
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
1885
docs/plans/2026-02-27-ui-ux-overhaul-implementation.md
Normal file
1885
docs/plans/2026-02-27-ui-ux-overhaul-implementation.md
Normal file
File diff suppressed because it is too large
Load Diff
@@ -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<gtk::Label> {
|
||||
// 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);
|
||||
|
||||
@@ -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(©_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",
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<gtk::Widget>, 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);
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
184
src/window.rs
184
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::<i64>()) 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::<i64>()) 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::<i64>()) 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::<i64>()) 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::<i64>()) 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::<i64>()) 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::<gtk::Application>().unwrap();
|
||||
|
||||
Reference in New Issue
Block a user