use adw::prelude::*; use std::cell::RefCell; use std::rc::Rc; use gtk::gio; use crate::core::catalog; use crate::core::database::Database; use crate::i18n::i18n; use super::widgets; /// Build the catalog browser page. pub fn build_catalog_page(db: &Rc) -> adw::NavigationPage { let toast_overlay = adw::ToastOverlay::new(); let header = adw::HeaderBar::new(); let title = adw::WindowTitle::builder() .title(&i18n("App Catalog")) .subtitle(&i18n("Browse available AppImages")) .build(); header.set_title_widget(Some(&title)); // Search entry let search_entry = gtk::SearchEntry::builder() .placeholder_text(&i18n("Search apps...")) .hexpand(true) .build(); let search_bar = gtk::SearchBar::builder() .child(&search_entry) .search_mode_enabled(true) .build(); // Category filter let category_box = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(8) .margin_start(18) .margin_end(18) .build(); let category_scroll = gtk::ScrolledWindow::builder() .child(&category_box) .vscrollbar_policy(gtk::PolicyType::Never) .hscrollbar_policy(gtk::PolicyType::Automatic) .max_content_height(40) .build(); // Results list let results_box = gtk::ListBox::builder() .selection_mode(gtk::SelectionMode::None) .css_classes(["boxed-list"]) .margin_start(18) .margin_end(18) .margin_top(12) .margin_bottom(24) .build(); let clamp = adw::Clamp::builder() .maximum_size(800) .tightening_threshold(600) .build(); let content = gtk::Box::builder() .orientation(gtk::Orientation::Vertical) .spacing(12) .build(); content.append(&search_bar); content.append(&category_scroll); content.append(&results_box); clamp.set_child(Some(&content)); let scrolled = gtk::ScrolledWindow::builder() .child(&clamp) .vexpand(true) .build(); // Status page for empty state let empty_page = adw::StatusPage::builder() .icon_name("system-software-install-symbolic") .title(&i18n("App Catalog")) .description(&i18n("Refresh the catalog to browse available AppImages")) .build(); let refresh_btn = gtk::Button::builder() .label(&i18n("Refresh Catalog")) .halign(gtk::Align::Center) .css_classes(["suggested-action", "pill"]) .build(); empty_page.set_child(Some(&refresh_btn)); let stack = gtk::Stack::new(); 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"); update_catalog_subtitle(&title, app_count); } else { stack.set_visible_child_name("empty"); } toast_overlay.set_child(Some(&stack)); let toolbar_view = adw::ToolbarView::new(); toolbar_view.add_top_bar(&header); toolbar_view.set_content(Some(&toast_overlay)); // Refresh button in header let refresh_header_btn = gtk::Button::builder() .icon_name("view-refresh-symbolic") .tooltip_text(&i18n("Refresh catalog")) .build(); header.pack_end(&refresh_header_btn); // Category chips state let active_category: Rc>> = Rc::new(RefCell::new(None)); // Populate categories populate_categories(db, &category_box, &active_category, &results_box, &search_entry); // Initial population populate_results(db, "", None, &results_box, &toast_overlay); // Search handler { let db_ref = db.clone(); let results_ref = results_box.clone(); let toast_ref = toast_overlay.clone(); let cat_ref = active_category.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); }); } // Refresh handler (both buttons) let wire_refresh = |btn: >k::Button| { let db_ref = db.clone(); let stack_ref = stack.clone(); let results_ref = results_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(); 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 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(); glib::spawn_future_local(async move { let db_bg = Database::open().ok(); let result = gio::spawn_blocking(move || { 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()) } else { Err("No catalog sources configured".to_string()) } } else { Err("Failed to open database".to_string()) } }).await; btn_c.set_sensitive(true); 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); 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); // Store refresh timestamp let settings = gio::Settings::new(crate::config::APP_ID); let now = chrono::Utc::now().to_rfc3339_opts(chrono::SecondsFormat::Secs, true); settings.set_string("catalog-last-refreshed", &now).ok(); } Ok(Err(e)) => { log::error!("Catalog refresh failed: {}", e); toast_c.add_toast(adw::Toast::new("Catalog refresh failed")); } Err(_) => { log::error!("Catalog refresh thread panicked"); toast_c.add_toast(adw::Toast::new("Catalog refresh failed")); } } }); }); }; wire_refresh(&refresh_btn); wire_refresh(&refresh_header_btn); // Auto-refresh on first visit when catalog is empty if app_count == 0 { refresh_btn.emit_clicked(); } adw::NavigationPage::builder() .title(&i18n("Catalog")) .tag("catalog") .child(&toolbar_view) .build() } /// 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"); if last_refreshed.is_empty() { title.set_subtitle(&format!("{} apps available", app_count)); } else { let relative = widgets::relative_time(&last_refreshed); title.set_subtitle(&format!("{} apps - Refreshed {}", app_count, relative)); } } fn populate_results( db: &Rc, query: &str, category: Option<&str>, list_box: >k::ListBox, toast_overlay: &adw::ToastOverlay, ) { // Clear existing while let Some(row) = list_box.row_at_index(0) { list_box.remove(&row); } let results = db.search_catalog(query, category, 50).unwrap_or_default(); 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; } let toast_ref = toast_overlay.clone(); let db_ref = db.clone(); // Get installed app names for matching let installed_names: std::collections::HashSet = 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 &results { let row = adw::ActionRow::builder() .title(&app.name) .activatable(true) .build(); 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); } } // 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"); } } }); }); row.add_suffix(&install_btn); list_box.append(&row); } } fn populate_categories( db: &Rc, category_box: >k::Box, active_category: &Rc>>, results_box: >k::ListBox, search_entry: >k::SearchEntry, ) { // Clear existing while let Some(child) = category_box.first_child() { category_box.remove(&child); } let categories = db.get_catalog_categories().unwrap_or_default(); if categories.is_empty() { return; } // "All" chip let all_btn = gtk::ToggleButton::builder() .label(&i18n("All")) .active(true) .css_classes(["pill"]) .build(); category_box.append(&all_btn); // Top 10 category chips let buttons: Rc>> = 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); buttons.borrow_mut().push(btn.clone()); let cat_str = cat.clone(); let active_ref = active_category.clone(); let results_ref = results_box.clone(); let search_ref = search_entry.clone(); let db_ref = db.clone(); let buttons_ref = buttons.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()); 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); } }); } // "All" button handler { let active_ref = active_category.clone(); let results_ref = results_box.clone(); let search_ref = search_entry.clone(); let db_ref = db.clone(); let buttons_ref = buttons.clone(); all_btn.connect_toggled(move |btn| { if btn.is_active() { for other in buttons_ref.borrow().iter() { if other != btn { other.set_active(false); } } *active_ref.borrow_mut() = None; let query = search_ref.text().to_string(); let toast = adw::ToastOverlay::new(); populate_results(&db_ref, &query, None, &results_ref, &toast); } }); } }