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 { // 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(", ") }