Files
driftwood/src/ui/app_card.rs

192 lines
6.4 KiB
Rust

use gtk::prelude::*;
use gtk::accessible::Property as AccessibleProperty;
use crate::core::database::AppImageRecord;
use crate::core::wayland::WaylandStatus;
use super::widgets;
/// Build a grid card for an AppImage.
pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(6)
.halign(gtk::Align::Fill)
.build();
card.add_css_class("card");
let name = record.app_name.as_deref().unwrap_or(&record.filename);
// Icon (64x64) with integration emblem overlay
let icon_widget = widgets::app_icon(
record.icon_path.as_deref(),
name,
64,
);
icon_widget.add_css_class("icon-dropshadow");
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
let name_label = gtk::Label::builder()
.label(name)
.css_classes(["title-4"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.max_width_chars(20)
.build();
card.append(&name_label);
// 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(&meta_label);
// Description snippet (if available)
if let Some(ref desc) = record.description {
if !desc.is_empty() {
let snippet = if desc.len() > 60 {
format!("{}...", &desc[..desc.char_indices().take_while(|&(i, _)| i < 57).last().map(|(i, c)| i + c.len_utf8()).unwrap_or(57)])
} else {
desc.clone()
};
let desc_label = gtk::Label::builder()
.label(&snippet)
.css_classes(["caption", "dimmed"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.lines(2)
.wrap(true)
.xalign(0.5)
.max_width_chars(25)
.build();
card.append(&desc_label);
}
}
// Single most important badge (priority: Update > FUSE issue > Wayland issue)
let badge = build_priority_badge(record);
let has_badge = badge.is_some();
if let Some(badge) = badge {
let badge_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.halign(gtk::Align::Center)
.margin_top(2)
.build();
badge_box.append(&badge);
card.append(&badge_box);
}
// Status border: green for healthy, amber for attention needed
if record.integrated && !has_badge {
card.add_css_class("status-ok");
} else if has_badge {
card.add_css_class("status-attention");
}
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
}
/// Return the single most important badge for a record.
/// Priority: Analyzing > Update available > FUSE issue > Wayland issue.
pub fn build_priority_badge(record: &AppImageRecord) -> Option<gtk::Label> {
// 0. Analysis in progress (highest priority)
if record.app_name.is_none() && record.analysis_status.as_deref() != Some("complete") {
return Some(widgets::status_badge("Analyzing...", "info"));
}
// 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
// The database stores AppImageFuseStatus values (per-app), not FuseStatus (system).
// Check both: per-app statuses like "native_fuse"/"static_runtime" are fine,
// "extract_and_run" is slow but works, "cannot_launch" is a real problem.
// System statuses like "fully_functional" are also fine.
if let Some(ref fs) = record.fuse_status {
let is_ok = matches!(
fs.as_str(),
"native_fuse" | "static_runtime" | "fully_functional"
);
let is_slow = fs.as_str() == "extract_and_run";
if !is_ok && !is_slow {
return Some(widgets::status_badge("Needs setup", "warning"));
}
}
// 3. Portable / removable media
if record.is_portable {
return Some(widgets::status_badge("Portable", "info"));
}
// 4. 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("May look blurry", "neutral"));
}
}
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);
let mut parts = vec![name.to_string()];
if let Some(ref ver) = record.app_version {
parts.push(format!("version {}", ver));
}
parts.push(widgets::format_size(record.size_bytes));
if record.integrated {
parts.push("integrated".to_string());
}
if let Some(ref ws) = record.wayland_status {
let status = WaylandStatus::from_str(ws);
if status != WaylandStatus::Unknown {
parts.push(format!("Wayland: {}", status.label()));
}
}
parts.join(", ")
}