Files
driftwood/docs/plans/2026-02-27-ui-ux-overhaul-implementation.md
lashman 33cc8a757a 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.
2026-02-27 11:10:23 +02:00

1886 lines
63 KiB
Markdown

# UI/UX Overhaul Implementation Plan
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
**Goal:** Make Driftwood look and feel like a first-class GNOME app by overhauling cards, list rows, detail view, CSS, and adding right-click context menus.
**Architecture:** Replace custom `.app-card` CSS with libadwaita `.card` + `.activatable`. Use `adw::ViewStack`/`adw::ViewSwitcher` to split the detail view into 4 tabs. Add `gio::Menu`-driven context menus attached via `GtkGestureClick` on cards and rows. All changes are in 5 files: `style.css`, `app_card.rs`, `library_view.rs`, `detail_view.rs`, `window.rs`.
**Tech Stack:** Rust, gtk4-rs 0.11 (v4_18), libadwaita-rs 0.9 (v1_8), GTK CSS
---
### Task 1: CSS Foundation - Remove .app-card, Add New Classes
**Files:**
- Modify: `data/resources/style.css`
**Step 1: Update style.css**
Replace the entire `.app-card` block (lines 69-91), the dark mode `.app-card` block (lines 152-167), and the high contrast `.app-card` block (lines 171-177) with new minimal classes. Also update the `.detail-banner` block (lines 133-136).
Remove these CSS blocks entirely:
- `.app-card` (lines 69-85)
- `flowboxchild:focus-visible .app-card` (lines 87-91)
- Dark mode `.app-card` overrides (lines 153-166)
- High contrast `.app-card` and `flowboxchild:focus-visible .app-card` overrides (lines 171-177)
Add these new CSS rules in their place:
```css
/* ===== 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;
}
/* Detail banner gradient wash */
.detail-banner {
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;
}
```
Update the high contrast section to reference `.card` instead of `.app-card`:
```css
/* In high contrast section: */
flowboxchild:focus-visible .card {
outline-width: 3px;
}
```
**Step 2: Build to verify CSS compiles**
Run: `cargo build 2>&1 | head -5`
Expected: Compiles (CSS is loaded at runtime, not compile-checked, but Rust code referencing removed classes will break in later tasks)
**Step 3: Commit**
```bash
git add data/resources/style.css
git commit -m "style: replace .app-card CSS with libadwaita .card classes
Remove custom .app-card hover/active/dark/contrast rules. Add
.icon-rounded for list view icons, gradient .detail-banner, and
.detail-view-switcher for tabbed detail layout."
```
---
### Task 2: Card View Overhaul
**Files:**
- Modify: `src/ui/app_card.rs`
**Step 1: Rewrite build_app_card function**
Replace the entire `build_app_card` function body (lines 10-124) with:
```rust
pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(6)
.margin_top(14)
.margin_bottom(14)
.margin_start(14)
.margin_end(14)
.halign(gtk::Align::Center)
.build();
card.add_css_class("card");
card.set_size_request(200, -1);
// 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,
72,
);
icon_widget.add_css_class("icon-dropshadow");
// If integrated, overlay a small checkmark emblem
if record.integrated {
let overlay = gtk::Overlay::new();
overlay.set_child(Some(&icon_widget));
let emblem = gtk::Image::from_icon_name("emblem-ok-symbolic");
emblem.set_pixel_size(16);
emblem.add_css_class("integration-emblem");
emblem.set_halign(gtk::Align::End);
emblem.set_valign(gtk::Align::End);
emblem.update_property(&[AccessibleProperty::Label("Integrated into desktop menu")]);
overlay.add_overlay(&emblem);
card.append(&overlay);
} else {
card.append(&icon_widget);
}
// App name - use .title-3 for more visual weight
let name_label = gtk::Label::builder()
.label(name)
.css_classes(["title-3"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.max_width_chars(20)
.build();
// Version + size combined on one line
let version_text = record.app_version.as_deref().unwrap_or("");
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();
card.append(&name_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);
}
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);
child.update_property(&[AccessibleProperty::Label(&accessible_name)]);
child
}
```
**Step 2: Add the priority badge helper function**
Add this new function after `build_app_card`, before `build_accessible_label`:
```rust
/// Return the single most important badge for a card.
/// Priority: Update available > FUSE issue > Wayland issue.
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
}
```
**Step 3: Build and test**
Run: `cargo build 2>&1 | tail -3`
Expected: Compiles successfully
Run: `cargo test 2>&1 | tail -5`
Expected: All tests pass
**Step 4: Commit**
```bash
git add src/ui/app_card.rs
git commit -m "ui: overhaul card view with larger icons and single badge
72px icons with .icon-dropshadow, .title-3 for app names, version+size
on one combined line, single priority badge (Update > FUSE > Wayland),
libadwaita .card class replacing custom .app-card, 200px card width."
```
---
### Task 3: Library View - FlowBox and List Row Changes
**Files:**
- Modify: `src/ui/library_view.rs`
**Step 1: Update FlowBox settings**
In the `new()` method, change the FlowBox builder (around line 196) from:
```rust
let flow_box = gtk::FlowBox::builder()
.valign(gtk::Align::Start)
.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)
.build();
```
to:
```rust
let flow_box = gtk::FlowBox::builder()
.valign(gtk::Align::Start)
.selection_mode(gtk::SelectionMode::None)
.homogeneous(true)
.min_children_per_line(2)
.max_children_per_line(4)
.row_spacing(14)
.column_spacing(14)
.margin_top(14)
.margin_bottom(14)
.margin_start(14)
.margin_end(14)
.build();
```
Changes: max 4 columns (from 6), 14px spacing (from 12) to match GNOME HIG.
**Step 2: Add .rich-list to ListBox**
In the `new()` method, after the ListBox builder (around line 221), change:
```rust
list_box.add_css_class("boxed-list");
```
to:
```rust
list_box.add_css_class("boxed-list");
list_box.add_css_class("rich-list");
```
**Step 3: Rewrite build_list_row for structured subtitle and single badge**
Replace the entire `build_list_row` method (lines 459-543) with:
```rust
fn build_list_row(&self, record: &AppImageRecord) -> adw::ActionRow {
let name = record.app_name.as_deref().unwrap_or(&record.filename);
// Structured two-line subtitle:
// Line 1: Description snippet or file path (dimmed)
// Line 2: Version + size (caption, dimmed, numeric)
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()
}
} 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 (48x48 with rounded clipping and letter fallback)
let icon = widgets::app_icon(
record.icon_path.as_deref(),
name,
48,
);
icon.add_css_class("icon-rounded");
row.add_prefix(&icon);
// 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);
}
// Navigate arrow
let arrow = gtk::Image::from_icon_name("go-next-symbolic");
row.add_suffix(&arrow);
row
}
```
**Step 4: Make build_priority_badge public in app_card.rs**
In `src/ui/app_card.rs`, change:
```rust
fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {
```
to:
```rust
pub fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {
```
**Step 5: Build and test**
Run: `cargo build 2>&1 | tail -3`
Expected: Compiles successfully
Run: `cargo test 2>&1 | tail -5`
Expected: All tests pass
**Step 6: Commit**
```bash
git add src/ui/library_view.rs src/ui/app_card.rs
git commit -m "ui: overhaul list view and FlowBox grid settings
FlowBox max 4 columns with 14px spacing. ListBox gets .rich-list class.
List rows use 48px rounded icons, structured two-line subtitle (desc +
version/size), single priority badge instead of all badges."
```
---
### Task 4: Detail View - Tabbed Layout with ViewStack
**Files:**
- Modify: `src/ui/detail_view.rs`
This is the largest task. The detail view currently has one scrolling page with 3 PreferencesGroups. We restructure it into a hero banner + 4-tab ViewStack (Overview, System, Security, Storage).
**Step 1: Rewrite build_detail_page to use ViewStack**
Replace the `build_detail_page` function (lines 19-156) with:
```rust
pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::NavigationPage {
let name = record.app_name.as_deref().unwrap_or(&record.filename);
// Toast overlay for copy actions
let toast_overlay = adw::ToastOverlay::new();
// Main content container
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
// Hero banner (not scrolled, always visible at top)
let banner = build_banner(record);
content.append(&banner);
// ViewSwitcher (tab bar) - inline style, between banner and tab content
let view_stack = adw::ViewStack::new();
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);
// Build tab pages
let overview_page = build_overview_tab(record, db);
view_stack.add_titled(&overview_page, Some("overview"), "Overview");
if let Some(page) = view_stack.page(&overview_page) {
page.set_icon_name(Some("info-symbolic"));
}
let system_page = build_system_tab(record, db);
view_stack.add_titled(&system_page, Some("system"), "System");
if let Some(page) = view_stack.page(&system_page) {
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");
if let Some(page) = view_stack.page(&security_page) {
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");
if let Some(page) = view_stack.page(&storage_page) {
page.set_icon_name(Some("drive-harddisk-symbolic"));
}
// Scrollable area for tab content
let scrolled = gtk::ScrolledWindow::builder()
.child(&view_stack)
.vexpand(true)
.build();
content.append(&scrolled);
toast_overlay.set_child(Some(&content));
// Header bar with per-app actions
let header = adw::HeaderBar::new();
let launch_button = gtk::Button::builder()
.label("Launch")
.tooltip_text("Launch this AppImage")
.build();
launch_button.add_css_class("suggested-action");
launch_button.update_property(&[
gtk::accessible::Property::Label("Launch application"),
]);
let record_id = record.id;
let path = record.path.clone();
let db_launch = db.clone();
launch_button.connect_clicked(move |_| {
let appimage_path = std::path::Path::new(&path);
let result = launcher::launch_appimage(
&db_launch,
record_id,
appimage_path,
"gui_detail",
&[],
&[],
);
match result {
launcher::LaunchResult::Started { child, method } => {
let pid = child.id();
log::info!("Launched AppImage: {} (PID: {}, method: {})", path, pid, method.as_str());
let db_wayland = db_launch.clone();
let path_clone = path.clone();
glib::spawn_future_local(async move {
glib::timeout_future(std::time::Duration::from_secs(3)).await;
let analysis_result = gio::spawn_blocking(move || {
wayland::analyze_running_process(pid)
}).await;
match analysis_result {
Ok(Ok(analysis)) => {
let status_label = analysis.status_label();
let status_str = analysis.as_status_str();
log::info!(
"Runtime Wayland analysis for {} (PID {}): {} (wayland_socket={}, x11={}, env_vars={})",
path_clone, analysis.pid, status_label,
analysis.has_wayland_socket,
analysis.has_x11_connection,
analysis.env_vars.len(),
);
db_wayland.update_runtime_wayland_status(
record_id, status_str,
).ok();
}
Ok(Err(e)) => {
log::debug!("Runtime analysis failed for PID {}: {}", pid, e);
}
Err(_) => {
log::debug!("Runtime analysis task failed for PID {}", pid);
}
}
});
}
launcher::LaunchResult::Failed(msg) => {
log::error!("Failed to launch: {}", msg);
}
}
});
header.pack_end(&launch_button);
let update_button = gtk::Button::builder()
.icon_name("software-update-available-symbolic")
.tooltip_text("Check for updates")
.build();
update_button.update_property(&[
gtk::accessible::Property::Label("Check for updates"),
]);
let record_for_update = record.clone();
let db_update = db.clone();
update_button.connect_clicked(move |btn| {
update_dialog::show_update_dialog(btn, &record_for_update, &db_update);
});
header.pack_end(&update_button);
let toolbar = adw::ToolbarView::new();
toolbar.add_top_bar(&header);
toolbar.set_content(Some(&toast_overlay));
adw::NavigationPage::builder()
.title(name)
.tag("detail")
.child(&toolbar)
.build()
}
```
**Step 2: Update the banner to use 96px icon**
Replace the `build_banner` function (lines 158-247) with:
```rust
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 (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
let text_col = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(4)
.valign(gtk::Align::Center)
.build();
let name_label = gtk::Label::builder()
.label(name)
.css_classes(["title-1"])
.halign(gtk::Align::Start)
.build();
text_col.append(&name_label);
// Version + architecture inline
let meta_parts: Vec<String> = [
record.app_version.as_deref().map(|v| v.to_string()),
record.architecture.as_deref().map(|a| a.to_string()),
]
.iter()
.filter_map(|p| p.clone())
.collect();
if !meta_parts.is_empty() {
let meta_label = gtk::Label::builder()
.label(&meta_parts.join(" - "))
.css_classes(["dimmed"])
.halign(gtk::Align::Start)
.build();
text_col.append(&meta_label);
}
// Description
if let Some(ref desc) = record.description {
if !desc.is_empty() {
let desc_label = gtk::Label::builder()
.label(desc)
.css_classes(["body"])
.halign(gtk::Align::Start)
.wrap(true)
.xalign(0.0)
.build();
text_col.append(&desc_label);
}
}
// Key status badges inline
let badge_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(6)
.margin_top(4)
.build();
if record.integrated {
badge_box.append(&widgets::status_badge("Integrated", "success"));
}
if let Some(ref ws) = record.wayland_status {
let status = WaylandStatus::from_str(ws);
if status != WaylandStatus::Unknown {
badge_box.append(&widgets::status_badge(status.label(), status.badge_class()));
}
}
if let (Some(ref latest), Some(ref current)) = (&record.latest_version, &record.app_version) {
if crate::core::updater::version_is_newer(latest, current) {
badge_box.append(&widgets::status_badge("Update available", "info"));
}
}
text_col.append(&badge_box);
banner.append(&text_col);
banner
}
```
**Step 3: Create the 4 tab builder functions**
Replace `build_system_integration_group` (lines 249-471), `build_updates_usage_group` (lines 493-580), and `build_security_storage_group` (lines 582-878) with these 4 new functions. Keep the `wayland_description` and `fuse_description` helper functions.
**Tab 1 - Overview:**
```rust
/// 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:**
```rust
/// 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();
let switch_row = adw::SwitchRow::builder()
.title("Add to application menu")
.subtitle("Creates a .desktop file and installs the icon")
.active(record.integrated)
.build();
let record_id = record.id;
let record_clone = record.clone();
let db_ref = db.clone();
let db_dialog = db.clone();
let record_dialog = record.clone();
let suppress = Rc::new(Cell::new(false));
let suppress_ref = suppress.clone();
switch_row.connect_active_notify(move |row| {
if suppress_ref.get() {
return;
}
if row.is_active() {
let row_clone = row.clone();
let suppress_inner = suppress_ref.clone();
integration_dialog::show_integration_dialog(
row,
&record_dialog,
&db_dialog,
move |success| {
if !success {
suppress_inner.set(true);
row_clone.set_active(false);
suppress_inner.set(false);
}
},
);
} else {
integrator::remove_integration(&record_clone).ok();
db_ref.set_integrated(record_id, false, None).ok();
}
});
integration_group.add(&switch_row);
if record.integrated {
if let Some(ref desktop_file) = record.desktop_file {
let row = adw::ActionRow::builder()
.title("Desktop file")
.subtitle(desktop_file)
.subtitle_selectable(true)
.build();
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();
let wayland_status = record
.wayland_status
.as_deref()
.map(WaylandStatus::from_str)
.unwrap_or(WaylandStatus::Unknown);
let wayland_row = adw::ActionRow::builder()
.title("Wayland")
.subtitle(wayland_description(&wayland_status))
.tooltip_text("Display protocol for Linux desktops")
.build();
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);
compat_group.add(&wayland_row);
let analyze_row = adw::ActionRow::builder()
.title("Analyze toolkit")
.subtitle("Inspect bundled libraries to detect UI toolkit")
.activatable(true)
.build();
let analyze_icon = gtk::Image::from_icon_name("system-search-symbolic");
analyze_icon.set_valign(gtk::Align::Center);
analyze_row.add_suffix(&analyze_icon);
let record_path_wayland = record.path.clone();
analyze_row.connect_activated(move |row| {
row.set_sensitive(false);
row.update_state(&[gtk::accessible::State::Busy(true)]);
row.set_subtitle("Analyzing...");
let row_clone = row.clone();
let path = record_path_wayland.clone();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
let appimage_path = std::path::Path::new(&path);
wayland::analyze_appimage(appimage_path)
})
.await;
row_clone.set_sensitive(true);
row_clone.update_state(&[gtk::accessible::State::Busy(false)]);
match result {
Ok(analysis) => {
let toolkit_label = analysis.toolkit.label();
let lib_count = analysis.libraries_found.len();
row_clone.set_subtitle(&format!(
"Toolkit: {} ({} libraries scanned)",
toolkit_label, lib_count,
));
}
Err(_) => {
row_clone.set_subtitle("Analysis failed");
}
}
});
});
compat_group.add(&analyze_row);
if let Some(ref runtime_status) = record.runtime_wayland_status {
let runtime_row = adw::ActionRow::builder()
.title("Runtime display protocol")
.subtitle(runtime_status)
.build();
if let Some(ref checked) = record.runtime_wayland_checked {
let info = gtk::Label::builder()
.label(checked)
.css_classes(["dimmed", "caption"])
.valign(gtk::Align::Center)
.build();
runtime_row.add_suffix(&info);
}
compat_group.add(&runtime_row);
}
let fuse_system = fuse::detect_system_fuse();
let fuse_status = record
.fuse_status
.as_deref()
.map(FuseStatus::from_str)
.unwrap_or(fuse_system.status.clone());
let fuse_row = adw::ActionRow::builder()
.title("FUSE")
.subtitle(fuse_description(&fuse_status))
.tooltip_text("Filesystem in Userspace - required for mounting AppImages")
.build();
let fuse_badge = widgets::status_badge_with_icon(
if fuse_status.is_functional() { "emblem-ok-symbolic" } else { "dialog-warning-symbolic" },
fuse_status.label(),
fuse_status.badge_class(),
);
fuse_badge.set_valign(gtk::Align::Center);
fuse_row.add_suffix(&fuse_badge);
compat_group.add(&fuse_row);
let appimage_path = std::path::Path::new(&record.path);
let app_fuse_status = fuse::determine_app_fuse_status(&fuse_system, appimage_path);
let launch_method_row = adw::ActionRow::builder()
.title("Launch method")
.subtitle(app_fuse_status.label())
.build();
let launch_badge = widgets::status_badge(
fuse_system.status.as_str(),
app_fuse_status.badge_class(),
);
launch_badge.set_valign(gtk::Align::Center);
launch_method_row.add_suffix(&launch_badge);
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();
let current_mode = record
.sandbox_mode
.as_deref()
.map(SandboxMode::from_str)
.unwrap_or(SandboxMode::None);
let firejail_available = launcher::has_firejail();
let sandbox_subtitle = if firejail_available {
format!("Current mode: {}", current_mode.label())
} else {
"Firejail is not installed".to_string()
};
let firejail_row = adw::SwitchRow::builder()
.title("Firejail sandbox")
.subtitle(&sandbox_subtitle)
.tooltip_text("Linux application sandboxing tool")
.active(current_mode == SandboxMode::Firejail)
.sensitive(firejail_available)
.build();
let record_id = record.id;
let db_ref = db.clone();
firejail_row.connect_active_notify(move |row| {
let mode = if row.is_active() {
SandboxMode::Firejail
} else {
SandboxMode::None
};
if let Err(e) = db_ref.update_sandbox_mode(record_id, Some(mode.as_str())) {
log::warn!("Failed to update sandbox mode: {}", e);
}
});
sandbox_group.add(&firejail_row);
if !firejail_available {
let info_row = adw::ActionRow::builder()
.title("Install Firejail")
.subtitle("sudo apt install firejail")
.build();
let badge = widgets::status_badge("Missing", "warning");
badge.set_valign(gtk::Align::Center);
info_row.add_suffix(&badge);
sandbox_group.add(&info_row);
}
inner.append(&sandbox_group);
clamp.set_child(Some(&inner));
tab.append(&clamp);
tab
}
```
**Tab 3 - Security:**
```rust
/// 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();
let clamp = adw::Clamp::builder()
.maximum_size(800)
.tightening_threshold(600)
.build();
let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(24)
.build();
let group = adw::PreferencesGroup::builder()
.title("Vulnerability Scanning")
.description("Check bundled libraries for known CVEs")
.build();
let libs = db.get_bundled_libraries(record.id).unwrap_or_default();
let summary = db.get_cve_summary(record.id).unwrap_or_default();
if libs.is_empty() {
let row = adw::ActionRow::builder()
.title("Security scan")
.subtitle("Not yet scanned for vulnerabilities")
.build();
let badge = widgets::status_badge("Not scanned", "neutral");
badge.set_valign(gtk::Align::Center);
row.add_suffix(&badge);
group.add(&row);
} else {
let lib_row = adw::ActionRow::builder()
.title("Bundled libraries")
.subtitle(&libs.len().to_string())
.build();
group.add(&lib_row);
if summary.total() == 0 {
let row = adw::ActionRow::builder()
.title("Vulnerabilities")
.subtitle("No known vulnerabilities")
.build();
let badge = widgets::status_badge("Clean", "success");
badge.set_valign(gtk::Align::Center);
row.add_suffix(&badge);
group.add(&row);
} else {
let row = adw::ActionRow::builder()
.title("Vulnerabilities")
.subtitle(&format!("{} found", summary.total()))
.build();
let badge = widgets::status_badge(summary.max_severity(), summary.badge_class());
badge.set_valign(gtk::Align::Center);
row.add_suffix(&badge);
group.add(&row);
}
}
// Scan button
let scan_row = adw::ActionRow::builder()
.title("Scan this AppImage")
.subtitle("Check bundled libraries for known CVEs")
.activatable(true)
.build();
let scan_icon = gtk::Image::from_icon_name("security-medium-symbolic");
scan_icon.set_valign(gtk::Align::Center);
scan_row.add_suffix(&scan_icon);
let record_id = record.id;
let record_path = record.path.clone();
scan_row.connect_activated(move |row| {
row.set_sensitive(false);
row.update_state(&[gtk::accessible::State::Busy(true)]);
row.set_subtitle("Scanning...");
let row_clone = row.clone();
let path = record_path.clone();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("Failed to open database");
let appimage_path = std::path::Path::new(&path);
security::scan_and_store(&bg_db, record_id, appimage_path)
})
.await;
row_clone.set_sensitive(true);
row_clone.update_state(&[gtk::accessible::State::Busy(false)]);
match result {
Ok(scan_result) => {
let total = scan_result.total_cves();
if total == 0 {
row_clone.set_subtitle("No vulnerabilities found");
} else {
row_clone.set_subtitle(&format!(
"Found {} CVE{}", total, if total == 1 { "" } else { "s" }
));
}
}
Err(_) => {
row_clone.set_subtitle("Scan failed");
}
}
});
});
group.add(&scan_row);
inner.append(&group);
// Integrity group
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:**
```rust
/// 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();
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();
size_group.add(&appimage_row);
if !fp.paths.is_empty() {
let data_total = fp.data_total();
if data_total > 0 {
let total_row = adw::ActionRow::builder()
.title("Total disk footprint")
.subtitle(&format!(
"{} (AppImage) + {} (data) = {}",
widgets::format_size(record.size_bytes),
widgets::format_size(data_total as i64),
widgets::format_size(fp.total_size() as i64),
))
.build();
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()
.title("Discover data paths")
.subtitle("Search for config, data, and cache directories")
.activatable(true)
.build();
let discover_icon = gtk::Image::from_icon_name("folder-saved-search-symbolic");
discover_icon.set_valign(gtk::Align::Center);
discover_row.add_suffix(&discover_icon);
let record_clone = record.clone();
let record_id = record.id;
discover_row.connect_activated(move |row| {
row.set_sensitive(false);
row.set_subtitle("Discovering...");
let row_clone = row.clone();
let rec = record_clone.clone();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("Failed to open database");
footprint::discover_and_store(&bg_db, record_id, &rec);
footprint::get_footprint(&bg_db, record_id, rec.size_bytes as u64)
})
.await;
row_clone.set_sensitive(true);
match result {
Ok(fp) => {
let count = fp.paths.len();
if count == 0 {
row_clone.set_subtitle("No associated paths found");
} else {
row_clone.set_subtitle(&format!(
"Found {} path{} ({})",
count,
if count == 1 { "" } else { "s" },
widgets::format_size(fp.data_total() as i64),
));
}
}
Err(_) => {
row_clone.set_subtitle("Discovery failed");
}
}
});
});
paths_group.add(&discover_row);
// Individual discovered paths
for dp in &fp.paths {
if dp.exists {
let row = adw::ActionRow::builder()
.title(dp.path_type.label())
.subtitle(&*dp.path.to_string_lossy())
.subtitle_selectable(true)
.build();
let icon = gtk::Image::from_icon_name(dp.path_type.icon_name());
icon.set_pixel_size(16);
row.add_prefix(&icon);
let conf_badge = widgets::status_badge(
dp.confidence.as_str(),
dp.confidence.badge_class(),
);
conf_badge.set_valign(gtk::Align::Center);
row.add_suffix(&conf_badge);
let size_label = gtk::Label::builder()
.label(&widgets::format_size(dp.size_bytes as i64))
.css_classes(["dimmed", "caption"])
.valign(gtk::Align::Center)
.build();
row.add_suffix(&size_label);
paths_group.add(&row);
}
}
inner.append(&paths_group);
// File location group
let location_group = adw::PreferencesGroup::builder()
.title("File Location")
.build();
let path_row = adw::ActionRow::builder()
.title("Path")
.subtitle(&record.path)
.subtitle_selectable(true)
.build();
path_row.add_css_class("property");
let copy_path_btn = widgets::copy_button(&record.path, Some(toast_overlay));
copy_path_btn.set_valign(gtk::Align::Center);
path_row.add_suffix(&copy_path_btn);
let folder_path = std::path::Path::new(&record.path)
.parent()
.map(|p| p.to_string_lossy().to_string())
.unwrap_or_default();
if !folder_path.is_empty() {
let open_folder_btn = gtk::Button::builder()
.icon_name("folder-open-symbolic")
.tooltip_text("Open containing folder")
.valign(gtk::Align::Center)
.build();
open_folder_btn.add_css_class("flat");
open_folder_btn.update_property(&[
gtk::accessible::Property::Label("Open containing folder"),
]);
let folder = folder_path.clone();
open_folder_btn.connect_clicked(move |_| {
let file = gio::File::for_path(&folder);
let launcher = gtk::FileLauncher::new(Some(&file));
launcher.open_containing_folder(gtk::Window::NONE, None::<&gio::Cancellable>, |_| {});
});
path_row.add_suffix(&open_folder_btn);
}
location_group.add(&path_row);
inner.append(&location_group);
clamp.set_child(Some(&inner));
tab.append(&clamp);
tab
}
```
**Step 4: Build and test**
Run: `cargo build 2>&1 | tail -5`
Expected: Compiles successfully
Run: `cargo test 2>&1 | tail -5`
Expected: All tests pass
**Step 5: Commit**
```bash
git add src/ui/detail_view.rs
git commit -m "ui: restructure detail view into tabbed layout
Replace single scrolling page with ViewStack/ViewSwitcher tabs:
Overview (updates, usage, file info), System (integration, compatibility,
sandboxing), Security (CVE scanning, integrity), Storage (disk usage,
data paths, file location). 96px hero banner with gradient background."
```
---
### Task 5: Right-Click Context Menu - Window Actions
**Files:**
- Modify: `src/window.rs`
**Step 1: Add parameterized context menu actions**
In `setup_window_actions()`, after the existing action entries and before the `self.add_action_entries(...)` call (around line 361), add new parameterized actions. Since `ActionEntry` doesn't support parameterized actions, we create them separately using `gio::SimpleAction`.
Add the following code right before the `self.add_action_entries([...])` call:
```rust
// --- 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 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) {
glib::spawn_future_local(async move {
let path = record.path.clone();
let result = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("DB open failed");
update_dialog::check_single_update(&bg_db, &record)
}).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 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) {
glib::spawn_future_local(async move {
let path = record.path.clone();
let result = gio::spawn_blocking(move || {
let bg_db = Database::open().expect("DB open failed");
let appimage_path = std::path::Path::new(&path);
security::scan_and_store(&bg_db, record_id, appimage_path)
}).await;
match result {
Ok(scan_result) => {
let total = scan_result.total_cves();
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));
}
}
Err(_) => 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 {
// For context menu, do a quick integrate without dialog
match integrator::integrate_appimage(&record) {
Ok(desktop_path) => {
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 launcher = gtk::FileLauncher::new(Some(&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) {
if let Some(display) = window.display().into() {
let clipboard = gtk::gdk::Display::clipboard(&display);
clipboard.set_text(&record.path);
toast_overlay.add_toast(adw::Toast::new("Path copied to clipboard"));
}
}
});
}
self.add_action(&copy_path_action);
```
**Step 2: Add missing imports at the top of window.rs**
Add these imports to the use block at the top (after line 14):
```rust
use crate::core::integrator;
use crate::core::launcher;
use crate::core::security;
```
**Step 3: Build and verify**
Run: `cargo build 2>&1 | tail -5`
Expected: Compiles. (Note: if `update_dialog::check_single_update` doesn't exist, we need to add it or use a toast-based approach instead.)
**Step 4: Commit**
```bash
git add src/window.rs
git commit -m "ui: add parameterized context menu actions to window
Define launch-appimage, check-update, scan-security, toggle-integration,
open-folder, and copy-path actions with i64 record ID parameter for
the right-click context menu on cards and list rows."
```
---
### Task 6: Right-Click Context Menu - Attach to Cards and List Rows
**Files:**
- Modify: `src/ui/library_view.rs`
**Step 1: Add context menu builder function**
Add a helper function at the end of `library_view.rs` (module-level, outside the `impl LibraryView` block):
```rust
/// Build the right-click context menu model for an AppImage.
fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu {
let menu = gtk::gio::Menu::new();
// Section 1: Launch
let section1 = gtk::gio::Menu::new();
section1.append(Some("Launch"), Some(&format!("win.launch-appimage(int64 {})", record.id)));
menu.append_section(None, &section1);
// Section 2: Actions
let section2 = gtk::gio::Menu::new();
section2.append(Some("Check for Updates"), Some(&format!("win.check-update(int64 {})", record.id)));
section2.append(Some("Scan for Vulnerabilities"), Some(&format!("win.scan-security(int64 {})", record.id)));
menu.append_section(None, &section2);
// Section 3: Integration + folder
let section3 = gtk::gio::Menu::new();
let integrate_label = if record.integrated { "Remove Integration" } else { "Integrate" };
section3.append(Some(integrate_label), Some(&format!("win.toggle-integration(int64 {})", record.id)));
section3.append(Some("Open Containing Folder"), Some(&format!("win.open-folder(int64 {})", record.id)));
menu.append_section(None, &section3);
// Section 4: Clipboard
let section4 = gtk::gio::Menu::new();
section4.append(Some("Copy Path"), Some(&format!("win.copy-path(int64 {})", record.id)));
menu.append_section(None, &section4);
menu
}
/// Attach a right-click context menu to a widget.
fn attach_context_menu(widget: &impl gtk::prelude::IsA<gtk::Widget>, menu_model: &gtk::gio::Menu) {
let popover = gtk::PopoverMenu::from_model(Some(menu_model));
popover.set_parent(widget.as_ref());
popover.set_has_arrow(false);
let click = gtk::GestureClick::new();
click.set_button(3); // Right click
let popover_ref = popover.clone();
click.connect_pressed(move |gesture, _, x, y| {
gesture.set_state(gtk::EventSequenceState::Claimed);
popover_ref.set_pointing_to(Some(&gtk::gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
popover_ref.popup();
});
widget.as_ref().add_controller(click);
// Long press for touch
let long_press = gtk::GestureLongPress::new();
let popover_ref = popover;
long_press.connect_pressed(move |gesture, x, y| {
gesture.set_state(gtk::EventSequenceState::Claimed);
popover_ref.set_pointing_to(Some(&gtk::gdk::Rectangle::new(x as i32, y as i32, 1, 1)));
popover_ref.popup();
});
widget.as_ref().add_controller(long_press);
}
```
**Step 2: Wire context menu to grid cards in populate()**
In the `populate()` method, after building the card (around line 432-433), add context menu attachment:
Change:
```rust
for record in &new_records {
// Grid card
let card = app_card::build_app_card(record);
self.flow_box.append(&card);
// List row
let row = self.build_list_row(record);
self.list_box.append(&row);
}
```
to:
```rust
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);
}
```
**Step 3: Build and test**
Run: `cargo build 2>&1 | tail -5`
Expected: Compiles successfully
Run: `cargo test 2>&1 | tail -5`
Expected: All tests pass
**Step 4: Commit**
```bash
git add src/ui/library_view.rs
git commit -m "ui: attach right-click context menu to cards and list rows
GtkPopoverMenu with gio::Menu model, triggered by GestureClick (button 3)
and GestureLongPress for touch. Menu sections: Launch, Check Updates /
Scan Vulnerabilities, Integrate / Open Folder, Copy Path."
```
---
### Task 7: Handle check_single_update Helper
**Files:**
- Modify: `src/ui/update_dialog.rs`
The context menu's check-update action calls `update_dialog::check_single_update`. We need to verify this function exists or add it.
**Step 1: Check if function exists**
Read `src/ui/update_dialog.rs` and look for `check_single_update` or `batch_check_updates`.
If `check_single_update` does not exist, add it:
```rust
/// Check for updates for a single AppImage. Returns true if update available.
pub fn check_single_update(db: &Database, record: &AppImageRecord) -> bool {
if record.update_type.is_none() {
return false;
}
match crate::core::updater::check_for_update(record) {
Ok(Some(latest)) => {
let is_newer = record
.app_version
.as_deref()
.map(|current| crate::core::updater::version_is_newer(&latest, current))
.unwrap_or(true);
if is_newer {
db.update_latest_version(record.id, &latest).ok();
return true;
}
false
}
_ => false,
}
}
```
**Step 2: Build and test**
Run: `cargo build 2>&1 | tail -5`
Expected: Compiles
Run: `cargo test 2>&1 | tail -5`
Expected: All tests pass
**Step 3: Commit**
```bash
git add src/ui/update_dialog.rs
git commit -m "ui: add check_single_update helper for context menu"
```
---
### Task 8: Final Build Verification and Polish
**Files:**
- All modified files
**Step 1: Full build**
Run: `cargo build 2>&1`
Expected: Zero errors. Fix any warnings.
**Step 2: Run tests**
Run: `cargo test 2>&1`
Expected: All 128+ tests pass.
**Step 3: Visual verification**
Run: `cargo run`
Verify:
1. Card view: 200px cards, 72px icons, .title-3 names, version+size combined, single badge
2. List view: 48px rounded icons, .rich-list spacing, structured subtitle, single badge
3. Detail view: 96px icon banner with gradient, ViewSwitcher with 4 tabs
4. Right-click on card or list row opens context menu
5. All context menu actions work (Launch, Check Updates, Scan, Integrate, Open Folder, Copy Path)
6. Light and dark mode look correct
7. Keyboard navigation works (Tab, Enter, Escape)
**Step 4: Commit any final fixes**
```bash
git add -u
git commit -m "ui: final polish and fixes for UI/UX overhaul"
```
---
## Files Modified (Summary)
| File | Task | Changes |
|------|------|---------|
| `data/resources/style.css` | 1 | Remove .app-card CSS, add .icon-rounded, .detail-banner gradient, .detail-view-switcher |
| `src/ui/app_card.rs` | 2 | 72px icon, .title-3, version+size combined, single priority badge, .card class, 200px width |
| `src/ui/library_view.rs` | 3, 6 | FlowBox max 4 cols, .rich-list, list row restructure, context menu build + attach |
| `src/ui/detail_view.rs` | 4 | 96px banner, ViewStack/ViewSwitcher, 4 tab pages (Overview, System, Security, Storage) |
| `src/window.rs` | 5 | 6 parameterized actions for context menu |
| `src/ui/update_dialog.rs` | 7 | check_single_update helper |
## Verification
After all tasks:
1. `cargo build` - zero errors, zero warnings
2. `cargo test` - all 128+ tests pass
3. Visual verification of all three views in light + dark mode
4. Right-click context menu works on cards and list rows
5. Detail view tabs switch correctly, content is correctly distributed
6. Keyboard navigation preserved
7. All WCAG AAA compliance preserved