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:
@@ -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);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user