Add GitHub metadata enrichment for catalog apps

Enrich catalog apps with GitHub API data (stars, version, downloads,
release date) via two strategies: background drip for repo-level info
and on-demand fetch when opening a detail page.

- Add github_enrichment module with API calls, asset filtering, and
  architecture auto-detection for AppImage downloads
- DB migrations v14/v15 for GitHub metadata and release asset columns
- Extract github_owner/repo from feed links during catalog sync
- Display colored stat cards (stars, version, downloads, released) on
  detail pages with on-demand enrichment
- Show stars and version on browse tiles and featured carousel cards
- Replace install button with SplitButton dropdown when multiple arch
  assets available, preferring detected architecture
- Disable install button until enrichment completes to prevent stale
  AppImageHub URL downloads
- Keep enrichment banner visible on catalog page until truly complete,
  showing paused state when rate-limited
- Add GitHub token and auto-enrich toggle to preferences
This commit is contained in:
lashman
2026-02-28 16:49:13 +02:00
parent 92c51dc39e
commit f89aafca6a
15 changed files with 3027 additions and 224 deletions

812
src/ui/catalog_detail.rs Normal file
View File

@@ -0,0 +1,812 @@
use adw::prelude::*;
use std::cell::RefCell;
use std::rc::Rc;
use gtk::gio;
use crate::core::catalog;
use crate::core::database::{CatalogApp, Database};
use crate::core::github_enrichment;
use crate::core::github_enrichment::AppImageAsset;
use crate::i18n::i18n;
use super::catalog_tile;
use super::detail_view;
use super::widgets;
use crate::config::APP_ID;
/// Build a catalog app detail page for the given CatalogApp.
/// Returns an adw::NavigationPage that can be pushed onto a NavigationView.
pub fn build_catalog_detail_page(
app: &CatalogApp,
db: &Rc<Database>,
toast_overlay: &adw::ToastOverlay,
) -> adw::NavigationPage {
let page_title = app.name.clone();
let toolbar = adw::ToolbarView::new();
let header = adw::HeaderBar::new();
toolbar.add_top_bar(&header);
let scrolled = gtk::ScrolledWindow::builder()
.vexpand(true)
.build();
let clamp = adw::Clamp::builder()
.maximum_size(800)
.tightening_threshold(600)
.build();
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(24)
.margin_top(24)
.margin_bottom(24)
.margin_start(18)
.margin_end(18)
.build();
// --- Header section: icon + name + author + buttons ---
let header_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(18)
.build();
let icon = widgets::app_icon(None, &app.name, 96);
icon.add_css_class("icon-dropshadow");
icon.set_valign(gtk::Align::Start);
header_box.append(&icon);
let info_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(6)
.valign(gtk::Align::Center)
.hexpand(true)
.build();
let name_label = gtk::Label::builder()
.label(&app.name)
.css_classes(["title-1"])
.xalign(0.0)
.halign(gtk::Align::Start)
.build();
info_box.append(&name_label);
// Author (from homepage URL domain)
if let Some(ref homepage) = app.homepage {
let author_text = extract_author(homepage);
let author_label = gtk::Label::builder()
.label(&format!("by {}", author_text))
.css_classes(["dim-label"])
.xalign(0.0)
.halign(gtk::Align::Start)
.build();
info_box.append(&author_label);
}
// Button row
let button_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(8)
.margin_top(8)
.build();
// Check early if GitHub data needs loading (affects install button behavior)
let has_github = app.github_owner.is_some() && app.github_repo.is_some();
let needs_enrichment = has_github
&& (app.latest_version.is_none() || is_enrichment_stale(app.github_enriched_at.as_deref()));
let awaiting_github = needs_enrichment && app.github_download_url.is_none();
// Check if already installed
let installed_names: std::collections::HashSet<String> = db
.get_all_appimages()
.unwrap_or_default()
.iter()
.filter_map(|r| r.app_name.as_ref().map(|n| n.to_lowercase()))
.collect();
let is_installed = installed_names.contains(&app.name.to_lowercase());
let install_slot = gtk::Box::new(gtk::Orientation::Horizontal, 0);
if is_installed {
let installed_badge = widgets::status_badge(&i18n("Installed"), "success");
installed_badge.set_valign(gtk::Align::Center);
button_box.append(&installed_badge);
} else {
button_box.append(&install_slot);
if awaiting_github {
// GitHub release data not yet loaded - show disabled placeholder
let placeholder = gtk::Button::builder()
.label(&i18n("Loading..."))
.css_classes(["suggested-action", "pill"])
.sensitive(false)
.build();
install_slot.append(&placeholder);
} else {
populate_install_slot(
&install_slot,
&app.name,
&app.download_url,
app.github_download_url.as_deref(),
app.github_release_assets.as_deref(),
toast_overlay,
db,
app.homepage.as_deref(),
);
}
}
// Homepage button
if let Some(ref homepage) = app.homepage {
let homepage_btn = gtk::Button::builder()
.label(&i18n("Homepage"))
.css_classes(["flat", "pill"])
.build();
let hp_clone = homepage.clone();
homepage_btn.connect_clicked(move |btn| {
let launcher = gtk::UriLauncher::new(&hp_clone);
let root = btn.root().and_downcast::<gtk::Window>();
launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {});
});
button_box.append(&homepage_btn);
}
info_box.append(&button_box);
header_box.append(&info_box);
content.append(&header_box);
// --- GitHub stat cards row (between header and screenshots) ---
let stars_value_label = gtk::Label::builder()
.label(app.github_stars.filter(|&s| s > 0).map(|s| format_count(s)).as_deref().unwrap_or("-"))
.css_classes(["stat-value"])
.xalign(0.0)
.build();
let version_value_label = gtk::Label::builder()
.label(app.latest_version.as_deref().unwrap_or("-"))
.css_classes(["stat-value"])
.xalign(0.0)
.ellipsize(gtk::pango::EllipsizeMode::End)
.max_width_chars(14)
.build();
let downloads_value_label = gtk::Label::builder()
.label(app.github_downloads.filter(|&d| d > 0).map(|d| format_count(d)).as_deref().unwrap_or("-"))
.css_classes(["stat-value"])
.xalign(0.0)
.build();
let released_value_label = gtk::Label::builder()
.label(app.release_date.as_deref().map(|d| widgets::relative_time(d)).as_deref().unwrap_or("-"))
.css_classes(["stat-value"])
.xalign(0.0)
.build();
if has_github {
let stats_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(10)
.homogeneous(true)
.build();
let stars_card = build_stat_card("starred-symbolic", &stars_value_label, &i18n("Stars"));
stars_card.add_css_class("stat-stars");
stats_row.append(&stars_card);
let version_card = build_stat_card("tag-symbolic", &version_value_label, &i18n("Latest"));
version_card.add_css_class("stat-version");
stats_row.append(&version_card);
let downloads_card = build_stat_card("folder-download-symbolic", &downloads_value_label, &i18n("Downloads"));
downloads_card.add_css_class("stat-downloads");
stats_row.append(&downloads_card);
let released_card = build_stat_card("month-symbolic", &released_value_label, &i18n("Released"));
released_card.add_css_class("stat-released");
stats_row.append(&released_card);
content.append(&stats_row);
}
// Enrichment spinner (small, shown next to stats row while loading)
let enrich_spinner = gtk::Spinner::builder()
.spinning(false)
.visible(false)
.halign(gtk::Align::Start)
.width_request(16)
.height_request(16)
.build();
if has_github {
content.append(&enrich_spinner);
}
// On-demand enrichment: fetch release info if stale or missing
if has_github && needs_enrichment {
let app_id = app.id;
let owner = app.github_owner.clone().unwrap_or_default();
let repo = app.github_repo.clone().unwrap_or_default();
let db_ref = db.clone();
let stars_ref = stars_value_label.clone();
let version_ref = version_value_label.clone();
let downloads_ref = downloads_value_label.clone();
let released_ref = released_value_label.clone();
let spinner_ref = enrich_spinner.clone();
let install_slot_ref = install_slot.clone();
let toast_ref = toast_overlay.clone();
spinner_ref.set_visible(true);
spinner_ref.set_spinning(true);
let settings = gio::Settings::new(APP_ID);
let token = settings.string("github-token").to_string();
glib::spawn_future_local(async move {
let owner_c = owner.clone();
let repo_c = repo.clone();
let token_c = token.clone();
let result = gio::spawn_blocking(move || {
let bg_db = Database::open().ok();
if let Some(ref db) = bg_db {
let _ = github_enrichment::enrich_app_release_info(
db, app_id, &owner_c, &repo_c, &token_c,
);
let _ = github_enrichment::enrich_app_repo_info(
db, app_id, &owner_c, &repo_c, &token_c,
);
}
}).await;
spinner_ref.set_spinning(false);
spinner_ref.set_visible(false);
if let Ok(Some(updated)) = db_ref.get_catalog_app(app_id) {
if let Some(stars) = updated.github_stars.filter(|&s| s > 0) {
stars_ref.set_label(&format_count(stars));
}
if let Some(ref ver) = updated.latest_version {
version_ref.set_label(ver);
}
if let Some(downloads) = updated.github_downloads.filter(|&d| d > 0) {
downloads_ref.set_label(&format_count(downloads));
}
if let Some(ref date) = updated.release_date {
released_ref.set_label(&widgets::relative_time(date));
}
// Rebuild install button now that GitHub data is available
if awaiting_github && !is_installed {
populate_install_slot(
&install_slot_ref,
&updated.name,
&updated.download_url,
updated.github_download_url.as_deref(),
updated.github_release_assets.as_deref(),
&toast_ref,
&db_ref,
updated.homepage.as_deref(),
);
}
}
if result.is_err() {
log::warn!("On-demand enrichment thread error");
}
});
}
// --- Screenshots section (click to open lightbox) ---
if let Some(ref screenshots_str) = app.screenshots {
let paths: Vec<&str> = screenshots_str.split(';').filter(|s| !s.is_empty()).collect();
if !paths.is_empty() {
let screenshots_label = gtk::Label::builder()
.label(&i18n("Screenshots"))
.css_classes(["title-2"])
.xalign(0.0)
.halign(gtk::Align::Start)
.margin_top(12)
.build();
content.append(&screenshots_label);
let screenshot_scroll = gtk::ScrolledWindow::builder()
.vscrollbar_policy(gtk::PolicyType::Never)
.hscrollbar_policy(gtk::PolicyType::Automatic)
.height_request(360)
.build();
let screenshot_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.build();
// Store textures for lightbox access
let textures: Rc<RefCell<Vec<Option<gtk::gdk::Texture>>>> =
Rc::new(RefCell::new(vec![None; paths.len()]));
for (i, path) in paths.iter().enumerate() {
let frame = gtk::Frame::new(None);
frame.add_css_class("card");
frame.set_width_request(480);
frame.set_height_request(340);
// Spinner placeholder
let spinner = gtk::Spinner::builder()
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.spinning(true)
.width_request(48)
.height_request(48)
.build();
frame.set_child(Some(&spinner));
screenshot_box.append(&frame);
// Click handler for lightbox
let textures_click = textures.clone();
let click = gtk::GestureClick::new();
let idx = i;
click.connect_released(move |gesture, _, _, _| {
gesture.set_state(gtk::EventSequenceState::Claimed);
let t = textures_click.borrow();
if t.get(idx).is_some_and(|t| t.is_some()) {
if let Some(widget) = gesture.widget() {
if let Some(root) = gtk::prelude::WidgetExt::root(&widget) {
if let Ok(window) = root.downcast::<gtk::Window>() {
detail_view::show_screenshot_lightbox(
&window,
&textures_click,
idx,
);
}
}
}
}
});
frame.add_controller(click);
// Load screenshot asynchronously
let app_name = app.name.clone();
let screenshot_path = path.to_string();
let frame_ref = frame.clone();
let textures_ref = textures.clone();
glib::spawn_future_local(async move {
let name = app_name.clone();
let spath = screenshot_path.clone();
let load_idx = i;
let result = gio::spawn_blocking(move || {
catalog::cache_screenshot(&name, &spath, load_idx)
.map_err(|e| e.to_string())
}).await;
match result {
Ok(Ok(local_path)) => {
if let Ok(texture) = gtk::gdk::Texture::from_filename(&local_path) {
let picture = gtk::Picture::builder()
.paintable(&texture)
.content_fit(gtk::ContentFit::Contain)
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.build();
frame_ref.set_child(Some(&picture));
textures_ref.borrow_mut()[i] = Some(texture);
}
}
_ => {
let fallback = gtk::Label::builder()
.label("Screenshot unavailable")
.css_classes(["dim-label"])
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.build();
frame_ref.set_child(Some(&fallback));
}
}
});
}
screenshot_scroll.set_child(Some(&screenshot_box));
content.append(&screenshot_scroll);
}
}
// --- About section ---
if let Some(ref desc) = app.description {
if !desc.is_empty() {
let about_group = adw::PreferencesGroup::builder()
.title(&i18n("About"))
.build();
let plain_desc = catalog_tile::strip_html(desc);
let desc_label = gtk::Label::builder()
.label(&plain_desc)
.wrap(true)
.xalign(0.0)
.selectable(true)
.margin_top(8)
.margin_bottom(8)
.margin_start(12)
.margin_end(12)
.build();
let row = adw::ActionRow::new();
row.set_child(Some(&desc_label));
about_group.add(&row);
content.append(&about_group);
}
}
// --- Details section ---
let details_group = adw::PreferencesGroup::builder()
.title(&i18n("Details"))
.build();
if let Some(ref license) = app.license {
if !license.is_empty() {
let row = adw::ActionRow::builder()
.title(&i18n("License"))
.subtitle(license)
.subtitle_selectable(true)
.build();
details_group.add(&row);
}
}
if let Some(ref cats) = app.categories {
if !cats.is_empty() {
let row = adw::ActionRow::builder()
.title(&i18n("Categories"))
.subtitle(cats)
.build();
details_group.add(&row);
}
}
if let Some(ref homepage) = app.homepage {
let row = adw::ActionRow::builder()
.title(&i18n("Homepage"))
.subtitle(homepage)
.subtitle_selectable(true)
.activatable(true)
.build();
let arrow = gtk::Image::from_icon_name("external-link-symbolic");
arrow.set_valign(gtk::Align::Center);
row.add_suffix(&arrow);
let hp = homepage.clone();
row.connect_activated(move |row| {
let launcher = gtk::UriLauncher::new(&hp);
let root = row.root().and_downcast::<gtk::Window>();
launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {});
});
details_group.add(&row);
}
// Download URL
let dl_row = adw::ActionRow::builder()
.title(&i18n("Download"))
.subtitle(&app.download_url)
.subtitle_selectable(true)
.activatable(true)
.build();
let dl_arrow = gtk::Image::from_icon_name("external-link-symbolic");
dl_arrow.set_valign(gtk::Align::Center);
dl_row.add_suffix(&dl_arrow);
let dl_url = app.download_url.clone();
dl_row.connect_activated(move |row| {
let launcher = gtk::UriLauncher::new(&dl_url);
let root = row.root().and_downcast::<gtk::Window>();
launcher.launch(root.as_ref(), gio::Cancellable::NONE, |_| {});
});
details_group.add(&dl_row);
content.append(&details_group);
// --- Status section ---
let status_group = adw::PreferencesGroup::builder()
.title(&i18n("Status"))
.build();
let status_row = adw::ActionRow::builder()
.title(&i18n("Installed"))
.build();
let status_badge = if is_installed {
widgets::status_badge(&i18n("Yes"), "success")
} else {
widgets::status_badge(&i18n("No"), "neutral")
};
status_badge.set_valign(gtk::Align::Center);
status_row.add_suffix(&status_badge);
status_group.add(&status_row);
content.append(&status_group);
clamp.set_child(Some(&content));
scrolled.set_child(Some(&clamp));
toolbar.set_content(Some(&scrolled));
adw::NavigationPage::builder()
.title(&page_title)
.tag("catalog-detail")
.child(&toolbar)
.build()
}
/// Trigger an install from a URL. Handles async download, DB registration, and UI feedback.
fn do_install(
url: String,
app_name: String,
homepage: Option<String>,
toast_overlay: adw::ToastOverlay,
db: Rc<Database>,
widget: gtk::Widget,
) {
glib::spawn_future_local(async move {
let name = app_name.clone();
let hp = homepage.clone();
let dl_url = url.clone();
let result = gio::spawn_blocking(move || {
let install_dir = dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
.join("Applications");
std::fs::create_dir_all(&install_dir).ok();
let cat_app = catalog::CatalogApp {
name,
description: None,
categories: Vec::new(),
latest_version: None,
download_url: dl_url,
icon_url: None,
homepage: hp,
file_size: None,
architecture: None,
screenshots: Vec::new(),
license: None,
github_link: None,
};
catalog::install_from_catalog(&cat_app, &install_dir)
.map_err(|e| e.to_string())
}).await;
match result {
Ok(Ok(path)) => {
let filename = path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let size = std::fs::metadata(&path)
.map(|m| m.len() as i64)
.unwrap_or(0);
db.upsert_appimage(
&path.to_string_lossy(),
&filename,
Some(2),
size,
true,
None,
).ok();
toast_overlay.add_toast(adw::Toast::new("Installed successfully"));
if let Some(btn) = widget.downcast_ref::<gtk::Button>() {
btn.set_label("Installed");
} else if let Some(split) = widget.downcast_ref::<adw::SplitButton>() {
split.set_label("Installed");
}
}
Ok(Err(e)) => {
log::error!("Install failed: {}", e);
toast_overlay.add_toast(adw::Toast::new("Install failed"));
widget.set_sensitive(true);
if let Some(btn) = widget.downcast_ref::<gtk::Button>() {
btn.set_label("Install");
} else if let Some(split) = widget.downcast_ref::<adw::SplitButton>() {
split.set_label("Install");
}
}
Err(_) => {
log::error!("Install thread panicked");
toast_overlay.add_toast(adw::Toast::new("Install failed"));
widget.set_sensitive(true);
if let Some(btn) = widget.downcast_ref::<gtk::Button>() {
btn.set_label("Install");
} else if let Some(split) = widget.downcast_ref::<adw::SplitButton>() {
split.set_label("Install");
}
}
}
});
}
/// Populate the install button slot with the appropriate button (plain or split).
/// Clears any existing children first, so it can be called to rebuild after enrichment.
fn populate_install_slot(
slot: &gtk::Box,
app_name: &str,
download_url: &str,
github_download_url: Option<&str>,
github_release_assets: Option<&str>,
toast_overlay: &adw::ToastOverlay,
db: &Rc<Database>,
homepage: Option<&str>,
) {
// Clear existing children (e.g. the "Loading..." placeholder)
while let Some(child) = slot.first_child() {
slot.remove(&child);
}
let assets: Vec<AppImageAsset> = github_release_assets
.and_then(|json| serde_json::from_str(json).ok())
.unwrap_or_default();
let default_url = github_download_url
.unwrap_or(download_url)
.to_string();
if assets.len() > 1 {
// Multiple assets available - use SplitButton with dropdown
let menu = gio::Menu::new();
for asset in &assets {
let label = format_asset_label(&asset.name, asset.size);
menu.append(Some(&label), Some(&format!("install.asset::{}", asset.url)));
}
menu.append(
Some(&i18n("AppImageHub (original)")),
Some(&format!("install.asset::{}", download_url)),
);
let split_btn = adw::SplitButton::builder()
.label(&i18n("Install"))
.menu_model(&menu)
.css_classes(["suggested-action", "pill"])
.build();
let url_for_click = default_url;
let name_for_click = app_name.to_string();
let hp_for_click = homepage.map(|s| s.to_string());
let toast_for_click = toast_overlay.clone();
let db_for_click = db.clone();
split_btn.connect_clicked(move |btn| {
btn.set_sensitive(false);
btn.set_label("Installing...");
do_install(
url_for_click.clone(),
name_for_click.clone(),
hp_for_click.clone(),
toast_for_click.clone(),
db_for_click.clone(),
btn.upcast_ref::<gtk::Widget>().clone(),
);
});
let action_group = gio::SimpleActionGroup::new();
let asset_action = gio::SimpleAction::new("asset", Some(glib::VariantTy::STRING));
let name_for_asset = app_name.to_string();
let hp_for_asset = homepage.map(|s| s.to_string());
let toast_for_asset = toast_overlay.clone();
let db_for_asset = db.clone();
let split_ref = split_btn.clone();
asset_action.connect_activate(move |_, param| {
if let Some(url) = param.and_then(|p| p.str()) {
split_ref.set_sensitive(false);
split_ref.set_label("Installing...");
do_install(
url.to_string(),
name_for_asset.clone(),
hp_for_asset.clone(),
toast_for_asset.clone(),
db_for_asset.clone(),
split_ref.upcast_ref::<gtk::Widget>().clone(),
);
}
});
action_group.add_action(&asset_action);
split_btn.insert_action_group("install", Some(&action_group));
slot.append(&split_btn);
} else {
// Single asset or no GitHub assets - plain button
let install_btn = gtk::Button::builder()
.label(&i18n("Install"))
.css_classes(["suggested-action", "pill"])
.build();
let url_clone = default_url;
let name_clone = app_name.to_string();
let hp_clone = homepage.map(|s| s.to_string());
let toast_clone = toast_overlay.clone();
let db_clone = db.clone();
install_btn.connect_clicked(move |btn| {
btn.set_sensitive(false);
btn.set_label("Installing...");
do_install(
url_clone.clone(),
name_clone.clone(),
hp_clone.clone(),
toast_clone.clone(),
db_clone.clone(),
btn.upcast_ref::<gtk::Widget>().clone(),
);
});
slot.append(&install_btn);
}
}
/// Format an asset filename with size for the dropdown menu.
fn format_asset_label(name: &str, size: i64) -> String {
if size > 0 {
format!("{} ({})", name, widgets::format_size(size))
} else {
name.to_string()
}
}
/// Build a single stat card widget with icon, value label, and description label.
fn build_stat_card(icon_name: &str, value_label: &gtk::Label, label_text: &str) -> gtk::Box {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(4)
.hexpand(true)
.build();
card.add_css_class("stat-card");
let icon_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(6)
.build();
let icon = gtk::Image::from_icon_name(icon_name);
icon.set_pixel_size(14);
icon_row.append(&icon);
icon_row.append(value_label);
card.append(&icon_row);
let label = gtk::Label::builder()
.label(label_text)
.css_classes(["stat-label"])
.xalign(0.0)
.build();
card.append(&label);
card
}
/// Check if enrichment data is stale (>24 hours old).
fn is_enrichment_stale(enriched_at: Option<&str>) -> bool {
let Some(ts) = enriched_at else { return true };
let Ok(parsed) = chrono::NaiveDateTime::parse_from_str(ts, "%Y-%m-%d %H:%M:%S") else {
return true;
};
let now = chrono::Utc::now().naive_utc();
let elapsed = now.signed_duration_since(parsed);
elapsed.num_hours() >= 24
}
/// Re-export format_count from widgets for use in this module.
fn format_count(n: i64) -> String {
widgets::format_count(n)
}
/// Extract author/org from a URL for display.
/// For GitHub/GitLab URLs, extracts the username/org from the path.
/// For other URLs, returns the domain.
fn extract_author(url: &str) -> String {
let stripped = url.trim_start_matches("https://")
.trim_start_matches("http://");
let parts: Vec<&str> = stripped.splitn(3, '/').collect();
let domain = parts.first().copied().unwrap_or("");
// For GitHub/GitLab, extract the org/user from the first path segment
if domain == "github.com" || domain == "gitlab.com" {
if let Some(org) = parts.get(1) {
if !org.is_empty() {
return org.to_string();
}
}
}
domain.to_string()
}

338
src/ui/catalog_tile.rs Normal file
View File

@@ -0,0 +1,338 @@
use gtk::prelude::*;
use crate::core::database::CatalogApp;
use super::widgets;
/// Build a catalog tile for the browse grid.
/// Left-aligned layout: icon (48px) at top, name, description, category badge.
/// Card fills its entire FlowBoxChild cell.
pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(6)
.halign(gtk::Align::Fill)
.valign(gtk::Align::Fill)
.hexpand(true)
.vexpand(true)
.build();
card.add_css_class("card");
card.add_css_class("catalog-tile");
let inner = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(6)
.margin_top(14)
.margin_bottom(14)
.margin_start(14)
.margin_end(14)
.vexpand(true)
.build();
// Icon (48px) - left aligned
let icon = widgets::app_icon(None, &app.name, 48);
icon.add_css_class("icon-dropshadow");
icon.set_halign(gtk::Align::Start);
inner.append(&icon);
// App name - left aligned
let name_label = gtk::Label::builder()
.label(&app.name)
.css_classes(["heading"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.max_width_chars(20)
.xalign(0.0)
.halign(gtk::Align::Start)
.build();
inner.append(&name_label);
// Description (always 2 lines for uniform height)
let plain = app.description.as_deref()
.filter(|d| !d.is_empty())
.map(|d| strip_html(d))
.unwrap_or_default();
let snippet: String = plain.chars().take(80).collect();
let text = if plain.is_empty() {
// Non-breaking space placeholder to reserve 2 lines
"\u{00a0}".to_string()
} else if snippet.len() < plain.len() {
format!("{}...", snippet.trim_end())
} else {
snippet
};
let desc_label = gtk::Label::builder()
.label(&text)
.css_classes(["caption", "dim-label"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.lines(2)
.wrap(true)
.xalign(0.0)
.max_width_chars(24)
.halign(gtk::Align::Start)
.build();
// Force 2-line height
desc_label.set_height_request(desc_label.preferred_size().1.height().max(36));
inner.append(&desc_label);
// Stats row (stars + version) - only if data exists
let has_stars = app.github_stars.is_some_and(|s| s > 0);
let has_version = app.latest_version.is_some();
if has_stars || has_version {
let stats_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.halign(gtk::Align::Start)
.build();
stats_row.add_css_class("catalog-stats-row");
if let Some(stars) = app.github_stars.filter(|&s| s > 0) {
let star_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(4)
.build();
let star_icon = gtk::Image::from_icon_name("starred-symbolic");
star_icon.set_pixel_size(12);
star_box.append(&star_icon);
let star_label = gtk::Label::new(Some(&widgets::format_count(stars)));
star_box.append(&star_label);
stats_row.append(&star_box);
}
if let Some(ref ver) = app.latest_version {
let ver_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(4)
.build();
let ver_icon = gtk::Image::from_icon_name("tag-symbolic");
ver_icon.set_pixel_size(12);
ver_box.append(&ver_icon);
let ver_label = gtk::Label::builder()
.label(ver.as_str())
.ellipsize(gtk::pango::EllipsizeMode::End)
.max_width_chars(12)
.build();
ver_box.append(&ver_label);
stats_row.append(&ver_box);
}
inner.append(&stats_row);
}
// Category badge - left aligned
if let Some(ref cats) = app.categories {
let first_cat: String = cats.split(';')
.next()
.or_else(|| cats.split(',').next())
.unwrap_or("")
.trim()
.to_string();
if !first_cat.is_empty() {
let badge = widgets::status_badge(&first_cat, "neutral");
badge.set_halign(gtk::Align::Start);
badge.set_margin_top(2);
inner.append(&badge);
}
}
card.append(&inner);
let child = gtk::FlowBoxChild::builder()
.child(&card)
.build();
child.add_css_class("activatable");
child
}
/// Build a featured banner card for the carousel.
/// Layout: screenshot preview on top, then icon + name + description + badge below.
/// Width is set dynamically by the carousel layout.
pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box {
let card = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(0)
.halign(gtk::Align::Fill)
.valign(gtk::Align::Fill)
.hexpand(true)
.vexpand(true)
.build();
card.add_css_class("card");
card.add_css_class("catalog-featured-card");
card.add_css_class("activatable");
card.set_widget_name(&format!("featured-{}", app.id));
// Screenshot preview area (top)
let screenshot_frame = gtk::Frame::new(None);
screenshot_frame.add_css_class("catalog-featured-screenshot");
screenshot_frame.set_height_request(160);
screenshot_frame.set_hexpand(true);
// Spinner placeholder until image loads
let spinner = gtk::Spinner::builder()
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.spinning(true)
.width_request(32)
.height_request(32)
.build();
screenshot_frame.set_child(Some(&spinner));
card.append(&screenshot_frame);
// Info section below screenshot
let info_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.margin_top(10)
.margin_bottom(10)
.margin_start(12)
.margin_end(12)
.build();
// Icon (48px)
let icon = widgets::app_icon(None, &app.name, 48);
icon.add_css_class("icon-dropshadow");
icon.set_valign(gtk::Align::Start);
info_box.append(&icon);
// Text column
let text_box = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(2)
.valign(gtk::Align::Center)
.hexpand(true)
.build();
// App name
let name_label = gtk::Label::builder()
.label(&app.name)
.css_classes(["heading"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.max_width_chars(28)
.xalign(0.0)
.halign(gtk::Align::Start)
.build();
text_box.append(&name_label);
// Description (1 line in featured since space is tight)
if let Some(ref desc) = app.description {
if !desc.is_empty() {
let plain = strip_html(desc);
let snippet: String = plain.chars().take(60).collect();
let text = if snippet.len() < plain.len() {
format!("{}...", snippet.trim_end())
} else {
snippet
};
let desc_label = gtk::Label::builder()
.label(&text)
.css_classes(["caption", "dim-label"])
.ellipsize(gtk::pango::EllipsizeMode::End)
.lines(1)
.xalign(0.0)
.max_width_chars(35)
.halign(gtk::Align::Start)
.build();
text_box.append(&desc_label);
}
}
// Badge row: category + stars
let badge_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(6)
.margin_top(2)
.build();
if let Some(ref cats) = app.categories {
let first_cat: String = cats.split(';')
.next()
.or_else(|| cats.split(',').next())
.unwrap_or("")
.trim()
.to_string();
if !first_cat.is_empty() {
let badge = widgets::status_badge(&first_cat, "info");
badge.set_halign(gtk::Align::Start);
badge_row.append(&badge);
}
}
if let Some(stars) = app.github_stars.filter(|&s| s > 0) {
let star_badge = widgets::status_badge_with_icon(
"starred-symbolic",
&widgets::format_count(stars),
"neutral",
);
star_badge.set_halign(gtk::Align::Start);
badge_row.append(&star_badge);
}
text_box.append(&badge_row);
info_box.append(&text_box);
card.append(&info_box);
card
}
/// Strip HTML tags from a string, returning plain text.
pub fn strip_html(html: &str) -> String {
let mut result = String::with_capacity(html.len());
let mut in_tag = false;
for ch in html.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => result.push(ch),
_ => {}
}
}
// Collapse whitespace
let collapsed: String = result.split_whitespace().collect::<Vec<&str>>().join(" ");
// Decode common HTML entities
collapsed
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_html_basic() {
assert_eq!(strip_html("<p>Hello world</p>"), "Hello world");
}
#[test]
fn test_strip_html_nested() {
assert_eq!(
strip_html("<p>Hello <b>bold</b> world</p>"),
"Hello bold world"
);
}
#[test]
fn test_strip_html_entities() {
assert_eq!(strip_html("&amp; &lt; &gt; &quot;"), "& < > \"");
}
#[test]
fn test_strip_html_multiline() {
let input = "<p>Line one</p>\n<p>Line two</p>";
assert_eq!(strip_html(input), "Line one Line two");
}
#[test]
fn test_strip_html_list() {
let input = "<ul>\n <li>Item 1</li>\n <li>Item 2</li>\n</ul>";
assert_eq!(strip_html(input), "Item 1 Item 2");
}
#[test]
fn test_strip_html_plain_text() {
assert_eq!(strip_html("No HTML here"), "No HTML here");
}
}

View File

@@ -5,12 +5,24 @@ use std::rc::Rc;
use gtk::gio;
use crate::core::catalog;
use crate::core::database::Database;
use crate::core::database::{CatalogApp, Database};
use crate::i18n::i18n;
use super::catalog_detail;
use super::catalog_tile;
use super::widgets;
/// Build the catalog browser page.
pub fn build_catalog_page(db: &Rc<Database>) -> adw::NavigationPage {
/// Build the catalog page with an internal NavigationView for drill-down.
/// Returns (NavigationView, enrichment_banner) so the window can control the banner.
pub fn build_catalog_page(db: &Rc<Database>) -> (adw::NavigationView, gtk::Box) {
let nav_view = adw::NavigationView::new();
let (browse_page, enrichment_banner) = build_browse_page(db, &nav_view);
nav_view.push(&browse_page);
(nav_view, enrichment_banner)
}
/// Build the main browse page with featured carousel + tile grid.
/// Returns (NavigationPage, enrichment_banner).
fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw::NavigationPage, gtk::Box) {
let toast_overlay = adw::ToastOverlay::new();
let header = adw::HeaderBar::new();
@@ -31,44 +43,197 @@ pub fn build_catalog_page(db: &Rc<Database>) -> adw::NavigationPage {
.search_mode_enabled(true)
.build();
// Category filter
let category_box = gtk::Box::builder()
// --- Featured section (paged carousel) ---
let featured_label = gtk::Label::builder()
.label(&i18n("Featured"))
.css_classes(["title-2"])
.xalign(0.0)
.halign(gtk::Align::Start)
.margin_start(18)
.margin_top(6)
.build();
// Stack for crossfade page transitions
let featured_stack = gtk::Stack::builder()
.transition_type(gtk::StackTransitionType::Crossfade)
.transition_duration(250)
.hexpand(true)
.build();
// Page state: all featured apps and current page index
let featured_apps: Rc<RefCell<Vec<CatalogApp>>> = Rc::new(RefCell::new(Vec::new()));
let featured_page: Rc<std::cell::Cell<usize>> = Rc::new(std::cell::Cell::new(0));
// Tracks which stack child name is active ("a" or "b") for crossfade toggling
let featured_flip: Rc<std::cell::Cell<bool>> = Rc::new(std::cell::Cell::new(false));
// Navigation arrows
let left_arrow = gtk::Button::builder()
.icon_name("go-previous-symbolic")
.css_classes(["circular", "osd"])
.valign(gtk::Align::Center)
.sensitive(false)
.build();
let right_arrow = gtk::Button::builder()
.icon_name("go-next-symbolic")
.css_classes(["circular", "osd"])
.valign(gtk::Align::Center)
.build();
// Carousel row: [<] [stack] [>]
let carousel_row = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(8)
.margin_start(18)
.margin_end(18)
.build();
carousel_row.append(&left_arrow);
carousel_row.append(&featured_stack);
carousel_row.append(&right_arrow);
let category_scroll = gtk::ScrolledWindow::builder()
.child(&category_box)
.vscrollbar_policy(gtk::PolicyType::Never)
.hscrollbar_policy(gtk::PolicyType::Automatic)
.max_content_height(40)
// Wire arrow navigation (page through featured apps with crossfade)
{
let apps_ref = featured_apps.clone();
let page_ref = featured_page.clone();
let flip_ref = featured_flip.clone();
let stack_ref = featured_stack.clone();
let left_ref = left_arrow.clone();
let right_ref = right_arrow.clone();
let db_ref = db.clone();
let nav_ref = nav_view.clone();
let toast_ref = toast_overlay.clone();
left_arrow.connect_clicked(move |_| {
let page = page_ref.get();
if page > 0 {
page_ref.set(page - 1);
show_featured_page(
&apps_ref, page - 1, &stack_ref, &flip_ref,
&left_ref, &right_ref,
&db_ref, &nav_ref, &toast_ref,
);
}
});
}
{
let apps_ref = featured_apps.clone();
let page_ref = featured_page.clone();
let flip_ref = featured_flip.clone();
let stack_ref = featured_stack.clone();
let left_ref = left_arrow.clone();
let right_ref = right_arrow.clone();
let db_ref = db.clone();
let nav_ref = nav_view.clone();
let toast_ref = toast_overlay.clone();
right_arrow.connect_clicked(move |_| {
let apps = apps_ref.borrow();
let page = page_ref.get();
let max_page = apps.len().saturating_sub(1) / CARDS_PER_PAGE;
if page < max_page {
drop(apps);
page_ref.set(page + 1);
show_featured_page(
&apps_ref, page + 1, &stack_ref, &flip_ref,
&left_ref, &right_ref,
&db_ref, &nav_ref, &toast_ref,
);
}
});
}
// Wrapping container for featured section
let featured_section = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(8)
.build();
featured_section.append(&featured_label);
featured_section.append(&carousel_row);
// Results list
let results_box = gtk::ListBox::builder()
// --- Category filter chips ---
let category_box = gtk::FlowBox::builder()
.selection_mode(gtk::SelectionMode::None)
.homogeneous(false)
.min_children_per_line(3)
.max_children_per_line(20)
.row_spacing(6)
.column_spacing(6)
.margin_start(18)
.margin_end(18)
.margin_top(6)
.build();
// --- "All Apps" section ---
let all_label = gtk::Label::builder()
.label(&i18n("All Apps"))
.css_classes(["title-2"])
.xalign(0.0)
.halign(gtk::Align::Start)
.margin_start(18)
.margin_top(6)
.build();
// FlowBox grid
let flow_box = gtk::FlowBox::builder()
.homogeneous(true)
.min_children_per_line(2)
.max_children_per_line(5)
.selection_mode(gtk::SelectionMode::None)
.css_classes(["boxed-list"])
.margin_start(18)
.margin_end(18)
.margin_top(12)
.margin_bottom(24)
.row_spacing(12)
.column_spacing(12)
.build();
let clamp = adw::Clamp::builder()
.maximum_size(800)
.tightening_threshold(600)
.maximum_size(1200)
.tightening_threshold(900)
.build();
let content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.spacing(12)
.spacing(8)
.build();
// Enrichment banner (hidden by default, shown by background enrichment)
let enrichment_banner = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(8)
.margin_start(18)
.margin_end(18)
.margin_top(6)
.visible(false)
.build();
enrichment_banner.add_css_class("card");
enrichment_banner.set_halign(gtk::Align::Fill);
let enrich_spinner = gtk::Spinner::builder()
.spinning(true)
.width_request(16)
.height_request(16)
.margin_start(12)
.valign(gtk::Align::Center)
.build();
enrich_spinner.set_widget_name("enrich-spinner");
enrichment_banner.append(&enrich_spinner);
let enrich_label = gtk::Label::builder()
.label(&i18n("Enriching app data from GitHub..."))
.css_classes(["dim-label"])
.hexpand(true)
.xalign(0.0)
.margin_top(8)
.margin_bottom(8)
.build();
enrich_label.set_widget_name("enrich-label");
enrichment_banner.append(&enrich_label);
// Layout order: search -> enrichment banner -> featured carousel -> categories -> all apps grid
content.append(&search_bar);
content.append(&category_scroll);
content.append(&results_box);
content.append(&enrichment_banner);
content.append(&featured_section);
content.append(&category_box.clone());
content.append(&all_label);
content.append(&flow_box);
clamp.set_child(Some(&content));
let scrolled = gtk::ScrolledWindow::builder()
@@ -76,7 +241,7 @@ pub fn build_catalog_page(db: &Rc<Database>) -> adw::NavigationPage {
.vexpand(true)
.build();
// Status page for empty state
// Empty state
let empty_page = adw::StatusPage::builder()
.icon_name("system-software-install-symbolic")
.title(&i18n("App Catalog"))
@@ -94,7 +259,6 @@ pub fn build_catalog_page(db: &Rc<Database>) -> adw::NavigationPage {
stack.add_named(&empty_page, Some("empty"));
stack.add_named(&scrolled, Some("results"));
// Show empty or results based on catalog data
let app_count = db.catalog_app_count().unwrap_or(0);
if app_count > 0 {
stack.set_visible_child_name("results");
@@ -105,9 +269,25 @@ pub fn build_catalog_page(db: &Rc<Database>) -> adw::NavigationPage {
toast_overlay.set_child(Some(&stack));
// Progress bar for catalog sync
let progress_bar = gtk::ProgressBar::builder()
.show_text(true)
.visible(false)
.margin_start(18)
.margin_end(18)
.margin_top(6)
.margin_bottom(6)
.build();
let toolbar_content = gtk::Box::builder()
.orientation(gtk::Orientation::Vertical)
.build();
toolbar_content.append(&progress_bar);
toolbar_content.append(&toast_overlay);
let toolbar_view = adw::ToolbarView::new();
toolbar_view.add_top_bar(&header);
toolbar_view.set_content(Some(&toast_overlay));
toolbar_view.set_content(Some(&toolbar_content));
// Refresh button in header
let refresh_header_btn = gtk::Button::builder()
@@ -120,56 +300,162 @@ pub fn build_catalog_page(db: &Rc<Database>) -> adw::NavigationPage {
let active_category: Rc<RefCell<Option<String>>> = Rc::new(RefCell::new(None));
// Populate categories
populate_categories(db, &category_box, &active_category, &results_box, &search_entry);
populate_categories(
db, &category_box, &active_category, &flow_box, &search_entry,
&featured_section, &all_label, nav_view, &toast_overlay,
);
// Initial population
populate_results(db, "", None, &results_box, &toast_overlay);
populate_featured(
db, &featured_apps, &featured_page, &featured_stack, &featured_flip,
&left_arrow, &right_arrow, nav_view, &toast_overlay,
);
populate_grid(db, "", None, &flow_box, &all_label, nav_view, &toast_overlay);
// Search handler
{
let db_ref = db.clone();
let results_ref = results_box.clone();
let toast_ref = toast_overlay.clone();
let flow_ref = flow_box.clone();
let cat_ref = active_category.clone();
let nav_ref = nav_view.clone();
let toast_ref = toast_overlay.clone();
let all_label_ref = all_label.clone();
let featured_section_ref = featured_section.clone();
search_entry.connect_search_changed(move |entry| {
let query = entry.text().to_string();
let cat = cat_ref.borrow().clone();
populate_results(&db_ref, &query, cat.as_deref(), &results_ref, &toast_ref);
let is_searching = !query.is_empty() || cat.is_some();
featured_section_ref.set_visible(!is_searching);
populate_grid(
&db_ref, &query, cat.as_deref(), &flow_ref,
&all_label_ref, &nav_ref, &toast_ref,
);
});
}
// Refresh handler (both buttons)
// Tile click handler for grid
{
let db_ref = db.clone();
let nav_ref = nav_view.clone();
let toast_ref = toast_overlay.clone();
flow_box.connect_child_activated(move |_, child| {
if let Some(app_id) = child.widget_name().strip_prefix("catalog-app-") {
if let Ok(id) = app_id.parse::<i64>() {
if let Ok(Some(app)) = db_ref.get_catalog_app(id) {
let detail = catalog_detail::build_catalog_detail_page(
&app, &db_ref, &toast_ref,
);
nav_ref.push(&detail);
}
}
}
});
}
// Refresh handler
let wire_refresh = |btn: &gtk::Button| {
let db_ref = db.clone();
let stack_ref = stack.clone();
let results_ref = results_box.clone();
let flow_ref = flow_box.clone();
let toast_ref = toast_overlay.clone();
let title_ref = title.clone();
let cat_box_ref = category_box.clone();
let active_cat_ref = active_category.clone();
let search_ref = search_entry.clone();
let featured_apps_ref = featured_apps.clone();
let featured_page_ref = featured_page.clone();
let featured_stack_ref = featured_stack.clone();
let featured_flip_ref = featured_flip.clone();
let left_arrow_ref = left_arrow.clone();
let right_arrow_ref = right_arrow.clone();
let all_label_ref = all_label.clone();
let featured_section_ref = featured_section.clone();
let nav_ref = nav_view.clone();
let progress_ref = progress_bar.clone();
btn.connect_clicked(move |btn| {
btn.set_sensitive(false);
let db_c = db_ref.clone();
let stack_c = stack_ref.clone();
let results_c = results_ref.clone();
let flow_c = flow_ref.clone();
let toast_c = toast_ref.clone();
let title_c = title_ref.clone();
let btn_c = btn.clone();
let cat_box_c = cat_box_ref.clone();
let active_cat_c = active_cat_ref.clone();
let search_c = search_ref.clone();
let featured_apps_c = featured_apps_ref.clone();
let featured_page_c = featured_page_ref.clone();
let featured_stack_c = featured_stack_ref.clone();
let featured_flip_c = featured_flip_ref.clone();
let left_arrow_c = left_arrow_ref.clone();
let right_arrow_c = right_arrow_ref.clone();
let all_label_c = all_label_ref.clone();
let featured_section_c = featured_section_ref.clone();
let nav_c = nav_ref.clone();
let progress_c = progress_ref.clone();
// Capture app count before refresh for delta calculation
let count_before = db_c.catalog_app_count().unwrap_or(0);
// Show progress bar
progress_c.set_visible(true);
progress_c.set_fraction(0.0);
progress_c.set_text(Some("Fetching catalog..."));
// Channel for progress updates from background thread
let (tx, rx) = std::sync::mpsc::channel::<catalog::SyncProgress>();
// Listen for progress on the main thread
let progress_listen = progress_c.clone();
glib::timeout_add_local(std::time::Duration::from_millis(50), move || {
while let Ok(progress) = rx.try_recv() {
match progress {
catalog::SyncProgress::FetchingFeed => {
progress_listen.set_fraction(0.0);
progress_listen.set_text(Some("Fetching catalog feed..."));
}
catalog::SyncProgress::FeedFetched { total } => {
progress_listen.set_fraction(0.05);
progress_listen.set_text(Some(&format!("Found {} apps, caching icons...", total)));
}
catalog::SyncProgress::CachingIcon { current, total, .. } => {
let frac = 0.05 + 0.85 * (current as f64 / total.max(1) as f64);
progress_listen.set_fraction(frac);
progress_listen.set_text(Some(
&format!("Caching icons ({}/{})", current, total),
));
}
catalog::SyncProgress::SavingApps { current, total } => {
let frac = 0.90 + 0.10 * (current as f64 / total.max(1) as f64);
progress_listen.set_fraction(frac);
progress_listen.set_text(Some(
&format!("Saving apps ({}/{})", current, total),
));
}
catalog::SyncProgress::Done { .. } => {
progress_listen.set_fraction(1.0);
progress_listen.set_text(Some("Complete"));
return glib::ControlFlow::Break;
}
}
}
if !progress_listen.is_visible() {
return glib::ControlFlow::Break;
}
glib::ControlFlow::Continue
});
glib::spawn_future_local(async move {
let db_bg = Database::open().ok();
let result = gio::spawn_blocking(move || {
let db_bg = Database::open().ok();
if let Some(ref db) = db_bg {
catalog::ensure_default_sources(db);
let sources = catalog::get_sources(db);
if let Some(source) = sources.first() {
catalog::sync_catalog(db, source)
.map_err(|e| e.to_string())
catalog::sync_catalog_with_progress(db, source, &move |p| {
tx.send(p).ok();
}).map_err(|e| e.to_string())
} else {
Err("No catalog sources configured".to_string())
}
@@ -179,20 +465,37 @@ pub fn build_catalog_page(db: &Rc<Database>) -> adw::NavigationPage {
}).await;
btn_c.set_sensitive(true);
progress_c.set_visible(false);
match result {
Ok(Ok(count)) => {
toast_c.add_toast(adw::Toast::new(
&format!("Catalog refreshed: {} apps", count),
));
update_catalog_subtitle(&title_c, count as i64);
Ok(Ok(_count)) => {
let count_after = db_c.catalog_app_count().unwrap_or(0);
let new_apps = count_after - count_before;
let toast_msg = if new_apps > 0 {
format!("Catalog refreshed: {} new apps added", new_apps)
} else {
"Catalog refreshed, no new apps".to_string()
};
toast_c.add_toast(adw::Toast::new(&toast_msg));
update_catalog_subtitle(&title_c, count_after);
stack_c.set_visible_child_name("results");
populate_categories(&db_c, &cat_box_c, &active_cat_c, &results_c, &search_c);
populate_results(&db_c, "", None, &results_c, &toast_c);
populate_categories(
&db_c, &cat_box_c, &active_cat_c, &flow_c, &search_c,
&featured_section_c, &all_label_c,
&nav_c, &toast_c,
);
populate_featured(
&db_c, &featured_apps_c, &featured_page_c,
&featured_stack_c, &featured_flip_c,
&left_arrow_c, &right_arrow_c, &nav_c, &toast_c,
);
populate_grid(
&db_c, "", None, &flow_c, &all_label_c, &nav_c, &toast_c,
);
// Store refresh timestamp
let settings = gio::Settings::new(crate::config::APP_ID);
let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
let now = chrono::Utc::now()
.to_rfc3339_opts(chrono::SecondsFormat::Secs, true);
settings.set_string("catalog-last-refreshed", &now).ok();
}
Ok(Err(e)) => {
@@ -216,14 +519,15 @@ pub fn build_catalog_page(db: &Rc<Database>) -> adw::NavigationPage {
refresh_btn.emit_clicked();
}
adw::NavigationPage::builder()
let page = adw::NavigationPage::builder()
.title(&i18n("Catalog"))
.tag("catalog")
.tag("catalog-browse")
.child(&toolbar_view)
.build()
.build();
(page, enrichment_banner)
}
/// Update the catalog subtitle to show app count and relative last-refreshed time.
fn update_catalog_subtitle(title: &adw::WindowTitle, app_count: i64) {
let settings = gtk::gio::Settings::new(crate::config::APP_ID);
let last_refreshed = settings.string("catalog-last-refreshed");
@@ -235,170 +539,188 @@ fn update_catalog_subtitle(title: &adw::WindowTitle, app_count: i64) {
}
}
fn populate_results(
const CARDS_PER_PAGE: usize = 3;
/// Populate featured apps data and show the first page.
fn populate_featured(
db: &Rc<Database>,
query: &str,
category: Option<&str>,
list_box: &gtk::ListBox,
featured_apps: &Rc<RefCell<Vec<CatalogApp>>>,
featured_page: &Rc<std::cell::Cell<usize>>,
featured_stack: &gtk::Stack,
featured_flip: &Rc<std::cell::Cell<bool>>,
left_arrow: &gtk::Button,
right_arrow: &gtk::Button,
nav_view: &adw::NavigationView,
toast_overlay: &adw::ToastOverlay,
) {
// Clear existing
while let Some(row) = list_box.row_at_index(0) {
list_box.remove(&row);
}
let apps = db.get_featured_catalog_apps(30).unwrap_or_default();
*featured_apps.borrow_mut() = apps;
featured_page.set(0);
show_featured_page(
featured_apps, 0, featured_stack, featured_flip,
left_arrow, right_arrow, db, nav_view, toast_overlay,
);
}
let results = db.search_catalog(query, category, 50).unwrap_or_default();
/// Display a specific page of featured cards with crossfade transition.
fn show_featured_page(
featured_apps: &Rc<RefCell<Vec<CatalogApp>>>,
page: usize,
stack: &gtk::Stack,
flip: &Rc<std::cell::Cell<bool>>,
left_arrow: &gtk::Button,
right_arrow: &gtk::Button,
db: &Rc<Database>,
nav_view: &adw::NavigationView,
toast_overlay: &adw::ToastOverlay,
) {
let apps = featured_apps.borrow();
let start = page * CARDS_PER_PAGE;
let end = (start + CARDS_PER_PAGE).min(apps.len());
let max_page = apps.len().saturating_sub(1) / CARDS_PER_PAGE;
if results.is_empty() {
let empty_row = adw::ActionRow::builder()
.title(&i18n("No results"))
.subtitle(&i18n("Try a different search or refresh the catalog"))
.build();
list_box.append(&empty_row);
return;
}
left_arrow.set_sensitive(page > 0);
right_arrow.set_sensitive(page < max_page);
let toast_ref = toast_overlay.clone();
let db_ref = db.clone();
// Build a new page container with equal-width cards
let page_box = gtk::Box::builder()
.orientation(gtk::Orientation::Horizontal)
.spacing(12)
.homogeneous(true)
.hexpand(true)
.build();
// Get installed app names for matching
let installed_names: std::collections::HashSet<String> = db
.get_all_appimages()
.unwrap_or_default()
.iter()
.filter_map(|r| r.app_name.as_ref().map(|n| n.to_lowercase()))
.collect();
for app in &apps[start..end] {
let tile = catalog_tile::build_featured_tile(app);
for app in &results {
let row = adw::ActionRow::builder()
.title(&app.name)
.activatable(true)
.build();
// Load screenshot asynchronously into the frame
if let Some(ref screenshots_str) = app.screenshots {
let first_screenshot = screenshots_str.split(';')
.find(|s| !s.is_empty());
if let Some(screenshot_path) = first_screenshot {
let app_name = app.name.clone();
let spath = screenshot_path.to_string();
let frame = tile.first_child()
.and_then(|w| w.downcast::<gtk::Frame>().ok());
if let Some(frame) = frame {
let frame_ref = frame.clone();
glib::spawn_future_local(async move {
let name = app_name.clone();
let sp = spath.clone();
let result = gio::spawn_blocking(move || {
catalog::cache_screenshot(&name, &sp, 0)
.map_err(|e| e.to_string())
}).await;
if let Some(ref desc) = app.description {
let snippet: String = desc.chars().take(80).collect();
let subtitle = if snippet.len() < desc.len() {
format!("{}...", snippet.trim_end())
} else {
snippet
};
row.set_subtitle(&subtitle);
}
// Category badges
if let Some(ref cats) = app.categories {
let first_cat: String = cats.split(';').next().unwrap_or("").to_string();
if !first_cat.is_empty() {
let badge = widgets::status_badge(&first_cat, "neutral");
badge.set_valign(gtk::Align::Center);
row.add_suffix(&badge);
match result {
Ok(Ok(local_path)) => {
if let Ok(texture) = gtk::gdk::Texture::from_filename(&local_path) {
let picture = gtk::Picture::builder()
.paintable(&texture)
.content_fit(gtk::ContentFit::Cover)
.halign(gtk::Align::Fill)
.valign(gtk::Align::Fill)
.build();
frame_ref.set_child(Some(&picture));
}
}
_ => {
let icon = gtk::Image::builder()
.icon_name("image-missing-symbolic")
.pixel_size(32)
.halign(gtk::Align::Center)
.valign(gtk::Align::Center)
.css_classes(["dim-label"])
.build();
frame_ref.set_child(Some(&icon));
}
}
});
}
}
}
// Show "Installed" badge if already installed, otherwise show Install button
let is_installed = installed_names.contains(&app.name.to_lowercase());
if is_installed {
let installed_badge = widgets::status_badge(&i18n("Installed"), "success");
installed_badge.set_valign(gtk::Align::Center);
row.add_suffix(&installed_badge);
list_box.append(&row);
continue;
}
// Install button
let install_btn = gtk::Button::builder()
.label(&i18n("Install"))
.valign(gtk::Align::Center)
.css_classes(["suggested-action"])
.build();
let download_url = app.download_url.clone();
let app_name = app.name.clone();
let homepage = app.homepage.clone();
let toast_install = toast_ref.clone();
let db_install = db_ref.clone();
install_btn.connect_clicked(move |btn| {
btn.set_sensitive(false);
btn.set_label("Installing...");
let url = download_url.clone();
let name = app_name.clone();
let hp = homepage.clone();
let toast_c = toast_install.clone();
let btn_c = btn.clone();
let db_c = db_install.clone();
glib::spawn_future_local(async move {
let result = gio::spawn_blocking(move || {
let install_dir = dirs::home_dir()
.unwrap_or_else(|| std::path::PathBuf::from("/tmp"))
.join("Applications");
std::fs::create_dir_all(&install_dir).ok();
let app = catalog::CatalogApp {
name: name.clone(),
description: None,
categories: Vec::new(),
latest_version: None,
download_url: url,
icon_url: None,
homepage: hp,
file_size: None,
architecture: None,
};
catalog::install_from_catalog(&app, &install_dir)
.map_err(|e| e.to_string())
}).await;
match result {
Ok(Ok(path)) => {
// Register in DB
let filename = path.file_name()
.map(|n| n.to_string_lossy().to_string())
.unwrap_or_default();
let size = std::fs::metadata(&path)
.map(|m| m.len() as i64)
.unwrap_or(0);
db_c.upsert_appimage(
&path.to_string_lossy(),
&filename,
Some(2),
size,
true,
None,
).ok();
toast_c.add_toast(adw::Toast::new("Installed successfully"));
btn_c.set_label("Installed");
}
Ok(Err(e)) => {
log::error!("Install failed: {}", e);
toast_c.add_toast(adw::Toast::new("Install failed"));
btn_c.set_sensitive(true);
btn_c.set_label("Install");
}
Err(_) => {
log::error!("Install thread panicked");
toast_c.add_toast(adw::Toast::new("Install failed"));
btn_c.set_sensitive(true);
btn_c.set_label("Install");
}
}
});
// Click handler
let db_ref = db.clone();
let nav_ref = nav_view.clone();
let toast_ref = toast_overlay.clone();
let app_id = app.id;
let click = gtk::GestureClick::new();
click.connect_released(move |gesture, _, _, _| {
gesture.set_state(gtk::EventSequenceState::Claimed);
if let Ok(Some(catalog_app)) = db_ref.get_catalog_app(app_id) {
let detail = catalog_detail::build_catalog_detail_page(
&catalog_app, &db_ref, &toast_ref,
);
nav_ref.push(&detail);
}
});
tile.add_controller(click);
row.add_suffix(&install_btn);
list_box.append(&row);
page_box.append(&tile);
}
// Crossfade: alternate between "page-a" and "page-b"
let current = flip.get();
let new_name = if current { "page-a" } else { "page-b" };
flip.set(!current);
// Remove stale child with this name (from 2 transitions ago)
if let Some(old) = stack.child_by_name(new_name) {
stack.remove(&old);
}
stack.add_named(&page_box, Some(new_name));
stack.set_visible_child_name(new_name);
}
/// Populate the main grid with catalog tiles.
fn populate_grid(
db: &Rc<Database>,
query: &str,
category: Option<&str>,
flow_box: &gtk::FlowBox,
all_label: &gtk::Label,
_nav_view: &adw::NavigationView,
_toast_overlay: &adw::ToastOverlay,
) {
// Clear existing
while let Some(child) = flow_box.first_child() {
flow_box.remove(&child);
}
let results = db.search_catalog(query, category, 200).unwrap_or_default();
if results.is_empty() {
all_label.set_label(&i18n("No results"));
return;
}
let label_text = if query.is_empty() && category.is_none() {
format!("{} ({})", i18n("All Apps"), results.len())
} else {
format!("{} ({})", i18n("Results"), results.len())
};
all_label.set_label(&label_text);
for app in &results {
let tile = catalog_tile::build_catalog_tile(app);
// Store the app ID in the widget name for retrieval on click
tile.set_widget_name(&format!("catalog-app-{}", app.id));
flow_box.append(&tile);
}
}
fn populate_categories(
db: &Rc<Database>,
category_box: &gtk::Box,
category_box: &gtk::FlowBox,
active_category: &Rc<RefCell<Option<String>>>,
results_box: &gtk::ListBox,
flow_box: &gtk::FlowBox,
search_entry: &gtk::SearchEntry,
featured_section: &gtk::Box,
all_label: &gtk::Label,
nav_view: &adw::NavigationView,
toast_overlay: &adw::ToastOverlay,
) {
// Clear existing
while let Some(child) = category_box.first_child() {
@@ -410,55 +732,62 @@ fn populate_categories(
return;
}
// "All" chip
let all_btn = gtk::ToggleButton::builder()
.label(&i18n("All"))
.active(true)
.css_classes(["pill"])
.build();
category_box.append(&all_btn);
category_box.insert(&all_btn, -1);
// Top 10 category chips
let buttons: Rc<RefCell<Vec<gtk::ToggleButton>>> = Rc::new(RefCell::new(vec![all_btn.clone()]));
let buttons: Rc<RefCell<Vec<gtk::ToggleButton>>> =
Rc::new(RefCell::new(vec![all_btn.clone()]));
for (cat, _count) in categories.iter().take(10) {
let btn = gtk::ToggleButton::builder()
.label(cat)
.css_classes(["pill"])
.build();
category_box.append(&btn);
category_box.insert(&btn, -1);
buttons.borrow_mut().push(btn.clone());
let cat_str = cat.clone();
let active_ref = active_category.clone();
let results_ref = results_box.clone();
let flow_ref = flow_box.clone();
let search_ref = search_entry.clone();
let db_ref = db.clone();
let buttons_ref = buttons.clone();
let featured_section_ref = featured_section.clone();
let all_label_ref = all_label.clone();
let nav_ref = nav_view.clone();
let toast_ref = toast_overlay.clone();
btn.connect_toggled(move |btn| {
if btn.is_active() {
// Deactivate others
for other in buttons_ref.borrow().iter() {
if other != btn {
other.set_active(false);
}
}
*active_ref.borrow_mut() = Some(cat_str.clone());
featured_section_ref.set_visible(false);
let query = search_ref.text().to_string();
// Use a dummy toast overlay for filtering
let toast = adw::ToastOverlay::new();
populate_results(&db_ref, &query, Some(&cat_str), &results_ref, &toast);
populate_grid(
&db_ref, &query, Some(&cat_str), &flow_ref,
&all_label_ref, &nav_ref, &toast_ref,
);
}
});
}
// "All" button handler
{
let active_ref = active_category.clone();
let results_ref = results_box.clone();
let flow_ref = flow_box.clone();
let search_ref = search_entry.clone();
let db_ref = db.clone();
let buttons_ref = buttons.clone();
let featured_section_ref = featured_section.clone();
let all_label_ref = all_label.clone();
let nav_ref = nav_view.clone();
let toast_ref = toast_overlay.clone();
all_btn.connect_toggled(move |btn| {
if btn.is_active() {
for other in buttons_ref.borrow().iter() {
@@ -467,9 +796,12 @@ fn populate_categories(
}
}
*active_ref.borrow_mut() = None;
featured_section_ref.set_visible(true);
let query = search_ref.text().to_string();
let toast = adw::ToastOverlay::new();
populate_results(&db_ref, &query, None, &results_ref, &toast);
populate_grid(
&db_ref, &query, None, &flow_ref,
&all_label_ref, &nav_ref, &toast_ref,
);
}
});
}

View File

@@ -2384,7 +2384,7 @@ fn fuse_install_command(status: &FuseStatus) -> Option<&'static str> {
/// Show a screenshot in a fullscreen lightbox window with prev/next navigation.
/// Uses a separate gtk::Window to avoid parent scroll position interference.
fn show_screenshot_lightbox(
pub fn show_screenshot_lightbox(
parent: &gtk::Window,
textures: &Rc<std::cell::RefCell<Vec<Option<gtk::gdk::Texture>>>>,
initial_index: usize,
@@ -2607,12 +2607,23 @@ fn fetch_favicon_async(url: &str, image: &gtk::Image) {
});
}
fn show_uninstall_dialog(
pub fn show_uninstall_dialog(
toast_overlay: &adw::ToastOverlay,
record: &AppImageRecord,
db: &Rc<Database>,
is_integrated: bool,
data_paths: &[(String, String, u64)],
) {
show_uninstall_dialog_with_callback(toast_overlay, record, db, is_integrated, data_paths, None);
}
pub fn show_uninstall_dialog_with_callback(
toast_overlay: &adw::ToastOverlay,
record: &AppImageRecord,
db: &Rc<Database>,
is_integrated: bool,
data_paths: &[(String, String, u64)],
on_complete: Option<Box<dyn FnOnce() + 'static>>,
) {
let name = record.app_name.as_deref().unwrap_or(&record.filename);
let dialog = adw::AlertDialog::builder()
@@ -2667,6 +2678,7 @@ fn show_uninstall_dialog(
let record_path = record.path.clone();
let db_ref = db.clone();
let toast_ref = toast_overlay.clone();
let on_complete = std::cell::Cell::new(on_complete);
dialog.connect_response(Some("uninstall"), move |_dlg, _response| {
// Remove integration if checked
if let Some(ref check) = integration_check {
@@ -2700,6 +2712,11 @@ fn show_uninstall_dialog(
toast_ref.add_toast(adw::Toast::new("AppImage uninstalled"));
// Run the completion callback if provided
if let Some(cb) = on_complete.take() {
cb();
}
// Navigate back (the detail view is now stale)
if let Some(nav) = toast_ref.ancestor(adw::NavigationView::static_type()) {
let nav: adw::NavigationView = nav.downcast().unwrap();

View File

@@ -589,13 +589,13 @@ impl LibraryView {
// Grid card
let card = app_card::build_app_card(record);
let card_menu = build_context_menu(record);
attach_context_menu(&card, &card_menu);
attach_context_menu(&card, &card_menu, record.id);
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);
attach_context_menu(&row, &row_menu, record.id);
self.list_box.append(&row);
}
@@ -812,15 +812,39 @@ fn build_context_menu(record: &AppImageRecord) -> gtk::gio::Menu {
section4.append(Some("Copy file location"), Some(&format!("win.copy-path(int64 {})", record.id)));
menu.append_section(None, &section4);
// Section 5: Destructive actions
let section5 = gtk::gio::Menu::new();
let uninstall_item = gtk::gio::MenuItem::new(None, Some(&format!("win.uninstall-appimage(int64 {})", record.id)));
uninstall_item.set_attribute_value("custom", Some(&"uninstall".to_variant()));
section5.append_item(&uninstall_item);
menu.append_section(None, &section5);
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));
fn attach_context_menu(widget: &impl gtk::prelude::IsA<gtk::Widget>, menu_model: &gtk::gio::Menu, record_id: i64) {
let popover = gtk::PopoverMenu::from_model_full(menu_model, gtk::PopoverMenuFlags::NESTED);
popover.set_parent(widget.as_ref());
popover.set_has_arrow(false);
// Add custom destructive-styled uninstall button
let uninstall_btn = gtk::Button::builder()
.label("Uninstall")
.build();
uninstall_btn.add_css_class("destructive-context-item");
// Left-align the label to match other menu items
if let Some(label) = uninstall_btn.child().and_then(|c| c.downcast::<gtk::Label>().ok()) {
label.set_halign(gtk::Align::Start);
}
uninstall_btn.set_action_name(Some("win.uninstall-appimage"));
uninstall_btn.set_action_target_value(Some(&record_id.to_variant()));
let popover_ref = popover.clone();
uninstall_btn.connect_clicked(move |_| {
popover_ref.popdown();
});
popover.add_child(&uninstall_btn, "uninstall");
// Unparent the popover when the widget is destroyed to avoid GTK warnings
let popover_cleanup = popover.clone();
widget.as_ref().connect_destroy(move |_| {

View File

@@ -1,5 +1,7 @@
pub mod app_card;
pub mod batch_update_dialog;
pub mod catalog_detail;
pub mod catalog_tile;
pub mod catalog_view;
pub mod cleanup_wizard;
pub mod dashboard;

View File

@@ -413,6 +413,44 @@ fn build_updates_page(settings: &gio::Settings) -> adw::PreferencesPage {
page.add(&security_group);
// Catalog Enrichment group
let enrichment_group = adw::PreferencesGroup::builder()
.title(&i18n("Catalog Enrichment"))
.description(&i18n("Fetch GitHub metadata (stars, version, downloads) for catalog apps"))
.build();
let auto_enrich_row = adw::SwitchRow::builder()
.title(&i18n("Auto-enrich catalog apps"))
.subtitle(&i18n("Fetch metadata from GitHub in the background"))
.active(settings.boolean("catalog-auto-enrich"))
.build();
let settings_enrich = settings.clone();
auto_enrich_row.connect_active_notify(move |row| {
settings_enrich.set_boolean("catalog-auto-enrich", row.is_active()).ok();
});
enrichment_group.add(&auto_enrich_row);
let token_row = adw::PasswordEntryRow::builder()
.title(&i18n("GitHub token"))
.build();
let current_token = settings.string("github-token");
if !current_token.is_empty() {
token_row.set_text(&current_token);
}
let settings_token = settings.clone();
token_row.connect_changed(move |row| {
settings_token.set_string("github-token", &row.text()).ok();
});
enrichment_group.add(&token_row);
let token_hint = adw::ActionRow::builder()
.title(&i18n("Optional - increases rate limit from 60 to 5,000 requests per hour"))
.css_classes(["dim-label"])
.build();
enrichment_group.add(&token_hint);
page.add(&enrichment_group);
page
}

View File

@@ -105,7 +105,7 @@ pub fn format_size(bytes: i64) -> String {
/// If the icon_path exists and is loadable, show the real icon.
/// Otherwise, generate a colored circle with the first letter of the app name.
pub fn app_icon(icon_path: Option<&str>, app_name: &str, pixel_size: i32) -> gtk::Widget {
// Try to load from path
// Try to load from explicit path
if let Some(icon_path) = icon_path {
let path = std::path::Path::new(icon_path);
if path.exists() {
@@ -119,6 +119,20 @@ pub fn app_icon(icon_path: Option<&str>, app_name: &str, pixel_size: i32) -> gtk
}
}
// Try cached catalog icon
let cache_dir = crate::core::catalog::icon_cache_dir();
let sanitized = crate::core::catalog::sanitize_filename(app_name);
let cached_path = cache_dir.join(format!("{}.png", sanitized));
if cached_path.exists() {
if let Ok(texture) = gtk::gdk::Texture::from_filename(&cached_path) {
let image = gtk::Image::builder()
.pixel_size(pixel_size)
.build();
image.set_paintable(Some(&texture));
return image.upcast();
}
}
// Letter-circle fallback
build_letter_icon(app_name, pixel_size)
}
@@ -383,6 +397,17 @@ pub fn relative_time(timestamp: &str) -> String {
}
}
/// Format a count with K/M suffixes for readability.
pub fn format_count(n: i64) -> String {
if n >= 1_000_000 {
format!("{:.1}M", n as f64 / 1_000_000.0)
} else if n >= 1_000 {
format!("{:.1}K", n as f64 / 1_000.0)
} else {
n.to_string()
}
}
/// Create a screen-reader live region announcement.
/// Inserts a hidden label with AccessibleRole::Alert into the given container,
/// which causes AT-SPI to announce the text to screen readers.