Add AppImageHub.com OCS API as primary catalog source
Integrate the Pling/OCS REST API (appimagehub.com) as the primary catalog source with richer metadata than the existing appimage.github.io feed. Backend: - Add OCS API fetch with pagination, lenient JSON deserializers for loosely typed numeric fields, and non-AppImage file filtering (.dmg, .exe, etc.) - Database migration v17 adds OCS-specific columns (ocs_id, downloads, score, typename, personid, description, summary, version, tags, etc.) - Deduplicate secondary source apps against OCS entries - Shrink OCS CDN icon URLs from 770x540 to 100x100 for faster loading - Clear stale screenshot and icon caches on sync - Extract GitHub repo links from OCS HTML descriptions - Add fetch_ocs_download_files() to get all version files for an app - Resolve fresh JWT download URLs per slot at install time Detail page: - Fetch OCS download files on page open and populate install SplitButton with version dropdown (newest first, filtered for AppImage only) - Show OCS metadata: downloads, score, author, typename, tags, comments, created/updated dates, architecture, filename, file size, MD5 - Prefer ocs_description (full HTML with features/changelog) over short summary for the About section - Add html_to_description() to preserve formatting (lists, paragraphs) - Remove redundant Download link from Links section - Escape ampersands in Pango markup subtitles (categories, typename, tags) Catalog view: - OCS source syncs first as primary, appimage.github.io as secondary - Featured apps consider OCS download counts alongside GitHub stars UI: - Add pulldown-cmark for GitHub README markdown rendering in detail pages - Add build_markdown_view() widget for rendered markdown content
This commit is contained in:
@@ -101,6 +101,7 @@ pub fn build_app_card(record: &AppImageRecord) -> gtk::FlowBoxChild {
|
||||
.child(&card)
|
||||
.build();
|
||||
child.add_css_class("activatable");
|
||||
super::widgets::set_pointer_cursor(&child);
|
||||
|
||||
// Accessible label for screen readers
|
||||
let accessible_name = build_accessible_label(record);
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -45,9 +45,11 @@ pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild {
|
||||
.build();
|
||||
inner.append(&name_label);
|
||||
|
||||
// Description (always 2 lines for uniform height)
|
||||
let plain = app.description.as_deref()
|
||||
// Description (always 2 lines for uniform height) - prefer OCS summary, then GitHub description
|
||||
let plain = app.ocs_summary.as_deref()
|
||||
.filter(|d| !d.is_empty())
|
||||
.or(app.github_description.as_deref().filter(|d| !d.is_empty()))
|
||||
.or(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();
|
||||
@@ -139,6 +141,7 @@ pub fn build_catalog_tile(app: &CatalogApp) -> gtk::FlowBoxChild {
|
||||
.child(&card)
|
||||
.build();
|
||||
child.add_css_class("activatable");
|
||||
widgets::set_pointer_cursor(&child);
|
||||
|
||||
child
|
||||
}
|
||||
@@ -158,6 +161,7 @@ pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box {
|
||||
card.add_css_class("card");
|
||||
card.add_css_class("catalog-featured-card");
|
||||
card.add_css_class("activatable");
|
||||
widgets::set_pointer_cursor(&card);
|
||||
card.set_widget_name(&format!("featured-{}", app.id));
|
||||
|
||||
// Screenshot preview area (top)
|
||||
@@ -212,27 +216,29 @@ pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box {
|
||||
.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);
|
||||
}
|
||||
// Description (1 line in featured since space is tight) - prefer OCS summary
|
||||
let feat_desc = app.ocs_summary.as_deref()
|
||||
.filter(|d| !d.is_empty())
|
||||
.or(app.github_description.as_deref().filter(|d| !d.is_empty()))
|
||||
.or(app.description.as_deref().filter(|d| !d.is_empty()));
|
||||
if let Some(desc) = feat_desc {
|
||||
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
|
||||
@@ -273,7 +279,71 @@ pub fn build_featured_tile(app: &CatalogApp) -> gtk::Box {
|
||||
card
|
||||
}
|
||||
|
||||
/// Strip HTML tags from a string, returning plain text.
|
||||
/// Convert HTML to readable formatted plain text, preserving paragraph breaks,
|
||||
/// line breaks, and list structure. Suitable for detail page descriptions.
|
||||
pub fn html_to_description(html: &str) -> String {
|
||||
let mut result = String::with_capacity(html.len());
|
||||
let mut in_tag = false;
|
||||
let mut tag_buf = String::new();
|
||||
|
||||
for ch in html.chars() {
|
||||
match ch {
|
||||
'<' => {
|
||||
in_tag = true;
|
||||
tag_buf.clear();
|
||||
}
|
||||
'>' if in_tag => {
|
||||
in_tag = false;
|
||||
let tag = tag_buf.trim().to_lowercase();
|
||||
let tag_name = tag.split_whitespace().next().unwrap_or("");
|
||||
match tag_name {
|
||||
"br" | "br/" => result.push('\n'),
|
||||
"/p" => result.push_str("\n\n"),
|
||||
"li" => result.push_str("\n - "),
|
||||
"/ul" | "/ol" => result.push('\n'),
|
||||
s if s.starts_with("/h") => result.push_str("\n\n"),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
_ if in_tag => tag_buf.push(ch),
|
||||
_ => result.push(ch),
|
||||
}
|
||||
}
|
||||
|
||||
// Decode HTML entities
|
||||
let decoded = result
|
||||
.replace("&", "&")
|
||||
.replace("<", "<")
|
||||
.replace(">", ">")
|
||||
.replace(""", "\"")
|
||||
.replace("'", "'")
|
||||
.replace("'", "'")
|
||||
.replace(" ", " ");
|
||||
|
||||
// Clean up: trim lines, collapse multiple blank lines
|
||||
let trimmed: Vec<&str> = decoded.lines().map(|l| l.trim()).collect();
|
||||
let mut cleaned = String::new();
|
||||
let mut prev_blank = false;
|
||||
for line in &trimmed {
|
||||
if line.is_empty() {
|
||||
if !prev_blank && !cleaned.is_empty() {
|
||||
cleaned.push('\n');
|
||||
prev_blank = true;
|
||||
}
|
||||
} else {
|
||||
if prev_blank {
|
||||
cleaned.push('\n');
|
||||
}
|
||||
cleaned.push_str(line);
|
||||
cleaned.push('\n');
|
||||
prev_blank = false;
|
||||
}
|
||||
}
|
||||
|
||||
cleaned.trim().to_string()
|
||||
}
|
||||
|
||||
/// Strip all HTML tags and collapse whitespace. Suitable for short descriptions on tiles.
|
||||
pub fn strip_html(html: &str) -> String {
|
||||
let mut result = String::with_capacity(html.len());
|
||||
let mut in_tag = false;
|
||||
|
||||
@@ -5,7 +5,7 @@ use std::rc::Rc;
|
||||
use gtk::gio;
|
||||
|
||||
use crate::core::catalog;
|
||||
use crate::core::database::{CatalogApp, Database};
|
||||
use crate::core::database::{CatalogApp, CatalogSortOrder, Database};
|
||||
use crate::i18n::i18n;
|
||||
use super::catalog_detail;
|
||||
use super::catalog_tile;
|
||||
@@ -50,7 +50,6 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
.xalign(0.0)
|
||||
.halign(gtk::Align::Start)
|
||||
.margin_start(18)
|
||||
.margin_top(6)
|
||||
.build();
|
||||
|
||||
// Stack for crossfade page transitions
|
||||
@@ -148,29 +147,75 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
featured_section.append(&featured_label);
|
||||
featured_section.append(&carousel_row);
|
||||
|
||||
// --- Category filter chips ---
|
||||
// --- Category filter tiles (wrapping grid) ---
|
||||
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)
|
||||
.max_children_per_line(6)
|
||||
.selection_mode(gtk::SelectionMode::None)
|
||||
.margin_start(18)
|
||||
.margin_end(18)
|
||||
.margin_top(6)
|
||||
.row_spacing(8)
|
||||
.column_spacing(8)
|
||||
.build();
|
||||
|
||||
// --- "All Apps" section ---
|
||||
// --- "All Apps" section header with sort dropdown ---
|
||||
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)
|
||||
.hexpand(true)
|
||||
.build();
|
||||
|
||||
let sort_options = [
|
||||
("Name (A-Z)", CatalogSortOrder::NameAsc),
|
||||
("Name (Z-A)", CatalogSortOrder::NameDesc),
|
||||
("Stars (most first)", CatalogSortOrder::StarsDesc),
|
||||
("Stars (fewest first)", CatalogSortOrder::StarsAsc),
|
||||
("Downloads (most first)", CatalogSortOrder::DownloadsDesc),
|
||||
("Downloads (fewest first)", CatalogSortOrder::DownloadsAsc),
|
||||
("Release date (newest first)", CatalogSortOrder::ReleaseDateDesc),
|
||||
("Release date (oldest first)", CatalogSortOrder::ReleaseDateAsc),
|
||||
];
|
||||
|
||||
let sort_model = gtk::StringList::new(
|
||||
&sort_options.iter().map(|(label, _)| *label).collect::<Vec<_>>(),
|
||||
);
|
||||
let sort_dropdown = gtk::DropDown::builder()
|
||||
.model(&sort_model)
|
||||
.selected(0)
|
||||
.valign(gtk::Align::Center)
|
||||
.tooltip_text(&i18n("Sort apps"))
|
||||
.build();
|
||||
sort_dropdown.add_css_class("flat");
|
||||
|
||||
let sort_icon = gtk::Image::builder()
|
||||
.icon_name("view-sort-descending-symbolic")
|
||||
.margin_end(4)
|
||||
.build();
|
||||
|
||||
let sort_row = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(8)
|
||||
.valign(gtk::Align::Center)
|
||||
.build();
|
||||
sort_row.append(&sort_icon);
|
||||
sort_row.append(&sort_dropdown);
|
||||
|
||||
let all_header = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(12)
|
||||
.margin_start(18)
|
||||
.margin_end(18)
|
||||
.build();
|
||||
all_header.append(&all_label);
|
||||
all_header.append(&sort_row);
|
||||
|
||||
// Sort state
|
||||
let active_sort: Rc<std::cell::Cell<CatalogSortOrder>> =
|
||||
Rc::new(std::cell::Cell::new(CatalogSortOrder::NameAsc));
|
||||
|
||||
// FlowBox grid
|
||||
let flow_box = gtk::FlowBox::builder()
|
||||
.homogeneous(true)
|
||||
@@ -191,7 +236,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
|
||||
let content = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.spacing(48)
|
||||
.build();
|
||||
|
||||
// Enrichment banner (hidden by default, shown by background enrichment)
|
||||
@@ -200,7 +245,6 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
.spacing(8)
|
||||
.margin_start(18)
|
||||
.margin_end(18)
|
||||
.margin_top(6)
|
||||
.visible(false)
|
||||
.build();
|
||||
enrichment_banner.add_css_class("card");
|
||||
@@ -231,8 +275,8 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
content.append(&search_bar);
|
||||
content.append(&enrichment_banner);
|
||||
content.append(&featured_section);
|
||||
content.append(&category_box.clone());
|
||||
content.append(&all_label);
|
||||
content.append(&category_box);
|
||||
content.append(&all_header);
|
||||
content.append(&flow_box);
|
||||
clamp.set_child(Some(&content));
|
||||
|
||||
@@ -301,7 +345,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
|
||||
// Populate categories
|
||||
populate_categories(
|
||||
db, &category_box, &active_category, &flow_box, &search_entry,
|
||||
db, &category_box, &active_category, &active_sort, &flow_box, &search_entry,
|
||||
&featured_section, &all_label, nav_view, &toast_overlay,
|
||||
);
|
||||
|
||||
@@ -310,13 +354,47 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
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);
|
||||
populate_grid(db, "", None, active_sort.get(), &flow_box, &all_label, nav_view, &toast_overlay);
|
||||
|
||||
// Sort dropdown handler
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let flow_ref = flow_box.clone();
|
||||
let cat_ref = active_category.clone();
|
||||
let sort_ref = active_sort.clone();
|
||||
let search_ref = search_entry.clone();
|
||||
let all_label_ref = all_label.clone();
|
||||
let nav_ref = nav_view.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
sort_dropdown.connect_selected_notify(move |dd| {
|
||||
let idx = dd.selected() as usize;
|
||||
let sort_options_local = [
|
||||
CatalogSortOrder::NameAsc,
|
||||
CatalogSortOrder::NameDesc,
|
||||
CatalogSortOrder::StarsDesc,
|
||||
CatalogSortOrder::StarsAsc,
|
||||
CatalogSortOrder::DownloadsDesc,
|
||||
CatalogSortOrder::DownloadsAsc,
|
||||
CatalogSortOrder::ReleaseDateDesc,
|
||||
CatalogSortOrder::ReleaseDateAsc,
|
||||
];
|
||||
let sort = sort_options_local.get(idx).copied().unwrap_or(CatalogSortOrder::NameAsc);
|
||||
sort_ref.set(sort);
|
||||
let query = search_ref.text().to_string();
|
||||
let cat = cat_ref.borrow().clone();
|
||||
populate_grid(
|
||||
&db_ref, &query, cat.as_deref(), sort,
|
||||
&flow_ref, &all_label_ref, &nav_ref, &toast_ref,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Search handler
|
||||
{
|
||||
let db_ref = db.clone();
|
||||
let flow_ref = flow_box.clone();
|
||||
let cat_ref = active_category.clone();
|
||||
let sort_ref = active_sort.clone();
|
||||
let nav_ref = nav_view.clone();
|
||||
let toast_ref = toast_overlay.clone();
|
||||
let all_label_ref = all_label.clone();
|
||||
@@ -327,8 +405,8 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
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,
|
||||
&db_ref, &query, cat.as_deref(), sort_ref.get(),
|
||||
&flow_ref, &all_label_ref, &nav_ref, &toast_ref,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -361,6 +439,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
let title_ref = title.clone();
|
||||
let cat_box_ref = category_box.clone();
|
||||
let active_cat_ref = active_category.clone();
|
||||
let active_sort_ref = active_sort.clone();
|
||||
let search_ref = search_entry.clone();
|
||||
let featured_apps_ref = featured_apps.clone();
|
||||
let featured_page_ref = featured_page.clone();
|
||||
@@ -383,6 +462,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
let btn_c = btn.clone();
|
||||
let cat_box_c = cat_box_ref.clone();
|
||||
let active_cat_c = active_cat_ref.clone();
|
||||
let active_sort_c = active_sort_ref.clone();
|
||||
let search_c = search_ref.clone();
|
||||
let featured_apps_c = featured_apps_ref.clone();
|
||||
let featured_page_c = featured_page_ref.clone();
|
||||
@@ -407,33 +487,69 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
let (tx, rx) = std::sync::mpsc::channel::<catalog::SyncProgress>();
|
||||
|
||||
// Listen for progress on the main thread
|
||||
// Track current source info for progress text
|
||||
let progress_listen = progress_c.clone();
|
||||
glib::timeout_add_local(std::time::Duration::from_millis(50), move || {
|
||||
let current_source_name: Rc<RefCell<String>> = Rc::new(RefCell::new(String::new()));
|
||||
let current_source_base: Rc<std::cell::Cell<f64>> = Rc::new(std::cell::Cell::new(0.0));
|
||||
let current_source_span: Rc<std::cell::Cell<f64>> = Rc::new(std::cell::Cell::new(1.0));
|
||||
glib::timeout_add_local(std::time::Duration::from_millis(50), {
|
||||
let src_name = current_source_name.clone();
|
||||
let src_base = current_source_base.clone();
|
||||
let src_span = current_source_span.clone();
|
||||
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);
|
||||
catalog::SyncProgress::SourceStarted { ref name, source_index, source_count } => {
|
||||
*src_name.borrow_mut() = name.clone();
|
||||
// Divide progress bar evenly across sources
|
||||
let span = 1.0 / source_count.max(1) as f64;
|
||||
src_base.set(source_index as f64 * span);
|
||||
src_span.set(span);
|
||||
let frac = src_base.get();
|
||||
progress_listen.set_fraction(frac);
|
||||
progress_listen.set_text(Some(
|
||||
&format!("Caching icons ({}/{})", current, total),
|
||||
&format!("Syncing {}...", name),
|
||||
));
|
||||
}
|
||||
catalog::SyncProgress::FetchingFeed => {
|
||||
let name = src_name.borrow();
|
||||
progress_listen.set_fraction(src_base.get());
|
||||
progress_listen.set_text(Some(
|
||||
&format!("{}: Fetching feed...", &*name),
|
||||
));
|
||||
}
|
||||
catalog::SyncProgress::FeedFetched { total } => {
|
||||
let name = src_name.borrow();
|
||||
progress_listen.set_fraction(src_base.get() + src_span.get() * 0.05);
|
||||
progress_listen.set_text(Some(
|
||||
&format!("{}: Found {} apps", &*name, total),
|
||||
));
|
||||
}
|
||||
catalog::SyncProgress::CachingIcon { current, total, .. } => {
|
||||
let name = src_name.borrow();
|
||||
let inner = 0.05 + 0.85 * (current as f64 / total.max(1) as f64);
|
||||
progress_listen.set_fraction(src_base.get() + src_span.get() * inner);
|
||||
progress_listen.set_text(Some(
|
||||
&format!("{}: Caching icons ({}/{})", &*name, 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);
|
||||
let name = src_name.borrow();
|
||||
let inner = 0.90 + 0.10 * (current as f64 / total.max(1) as f64);
|
||||
progress_listen.set_fraction(src_base.get() + src_span.get() * inner);
|
||||
progress_listen.set_text(Some(
|
||||
&format!("Saving apps ({}/{})", current, total),
|
||||
&format!("{}: Saving ({}/{})", &*name, current, total),
|
||||
));
|
||||
}
|
||||
catalog::SyncProgress::Done { .. } => {
|
||||
// Single source done - don't break, more sources may follow
|
||||
let name = src_name.borrow();
|
||||
progress_listen.set_fraction(src_base.get() + src_span.get());
|
||||
progress_listen.set_text(Some(
|
||||
&format!("{}: Complete", &*name),
|
||||
));
|
||||
}
|
||||
catalog::SyncProgress::AllDone => {
|
||||
progress_listen.set_fraction(1.0);
|
||||
progress_listen.set_text(Some("Complete"));
|
||||
return glib::ControlFlow::Break;
|
||||
@@ -444,7 +560,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
return glib::ControlFlow::Break;
|
||||
}
|
||||
glib::ControlFlow::Continue
|
||||
});
|
||||
}});
|
||||
|
||||
glib::spawn_future_local(async move {
|
||||
let result = gio::spawn_blocking(move || {
|
||||
@@ -452,13 +568,31 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
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_with_progress(db, source, &move |p| {
|
||||
tx.send(p).ok();
|
||||
}).map_err(|e| e.to_string())
|
||||
} else {
|
||||
Err("No catalog sources configured".to_string())
|
||||
if sources.is_empty() {
|
||||
return Err("No catalog sources configured".to_string());
|
||||
}
|
||||
// Sync all sources in order (OCS first as primary, then secondary)
|
||||
let enabled_sources: Vec<_> = sources.iter()
|
||||
.filter(|s| s.enabled)
|
||||
.collect();
|
||||
let source_count = enabled_sources.len() as u32;
|
||||
let mut total_count = 0u32;
|
||||
for (i, source) in enabled_sources.iter().enumerate() {
|
||||
tx.send(catalog::SyncProgress::SourceStarted {
|
||||
name: source.name.clone(),
|
||||
source_index: i as u32,
|
||||
source_count,
|
||||
}).ok();
|
||||
let tx_ref = tx.clone();
|
||||
match catalog::sync_catalog_with_progress(db, source, &move |p| {
|
||||
tx_ref.send(p).ok();
|
||||
}) {
|
||||
Ok(count) => total_count += count,
|
||||
Err(e) => eprintln!("Failed to sync source '{}': {}", source.name, e),
|
||||
}
|
||||
}
|
||||
tx.send(catalog::SyncProgress::AllDone).ok();
|
||||
Ok(total_count)
|
||||
} else {
|
||||
Err("Failed to open database".to_string())
|
||||
}
|
||||
@@ -480,7 +614,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
update_catalog_subtitle(&title_c, count_after);
|
||||
stack_c.set_visible_child_name("results");
|
||||
populate_categories(
|
||||
&db_c, &cat_box_c, &active_cat_c, &flow_c, &search_c,
|
||||
&db_c, &cat_box_c, &active_cat_c, &active_sort_c, &flow_c, &search_c,
|
||||
&featured_section_c, &all_label_c,
|
||||
&nav_c, &toast_c,
|
||||
);
|
||||
@@ -490,7 +624,7 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
&left_arrow_c, &right_arrow_c, &nav_c, &toast_c,
|
||||
);
|
||||
populate_grid(
|
||||
&db_c, "", None, &flow_c, &all_label_c, &nav_c, &toast_c,
|
||||
&db_c, "", None, active_sort_c.get(), &flow_c, &all_label_c, &nav_c, &toast_c,
|
||||
);
|
||||
|
||||
let settings = gio::Settings::new(crate::config::APP_ID);
|
||||
@@ -519,6 +653,8 @@ fn build_browse_page(db: &Rc<Database>, nav_view: &adw::NavigationView) -> (adw:
|
||||
refresh_btn.emit_clicked();
|
||||
}
|
||||
|
||||
widgets::apply_pointer_cursors(&toolbar_view);
|
||||
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title(&i18n("Catalog"))
|
||||
.tag("catalog-browse")
|
||||
@@ -679,6 +815,7 @@ fn populate_grid(
|
||||
db: &Rc<Database>,
|
||||
query: &str,
|
||||
category: Option<&str>,
|
||||
sort: CatalogSortOrder,
|
||||
flow_box: >k::FlowBox,
|
||||
all_label: >k::Label,
|
||||
_nav_view: &adw::NavigationView,
|
||||
@@ -689,7 +826,7 @@ fn populate_grid(
|
||||
flow_box.remove(&child);
|
||||
}
|
||||
|
||||
let results = db.search_catalog(query, category, 200).unwrap_or_default();
|
||||
let results = db.search_catalog(query, category, 200, sort).unwrap_or_default();
|
||||
|
||||
if results.is_empty() {
|
||||
all_label.set_label(&i18n("No results"));
|
||||
@@ -711,10 +848,54 @@ fn populate_grid(
|
||||
}
|
||||
}
|
||||
|
||||
/// Map a FreeDesktop category name to (icon_name, color_css_class).
|
||||
fn category_meta(name: &str) -> (&'static str, &'static str) {
|
||||
match name.to_lowercase().as_str() {
|
||||
"audio" => ("audio-x-generic-symbolic", "cat-purple"),
|
||||
"audiovideo" | "video" => ("camera-video-symbolic", "cat-red"),
|
||||
"game" => ("input-gaming-symbolic", "cat-green"),
|
||||
"graphics" => ("image-x-generic-symbolic", "cat-orange"),
|
||||
"development" => ("utilities-terminal-symbolic", "cat-blue"),
|
||||
"education" => ("accessories-dictionary-symbolic", "cat-amber"),
|
||||
"network" => ("network-workgroup-symbolic", "cat-purple"),
|
||||
"office" => ("x-office-document-symbolic", "cat-amber"),
|
||||
"science" => ("accessories-calculator-symbolic", "cat-blue"),
|
||||
"system" => ("emblem-system-symbolic", "cat-neutral"),
|
||||
"utility" => ("applications-utilities-symbolic", "cat-green"),
|
||||
_ => ("application-x-executable-symbolic", "cat-neutral"),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a category tile toggle button with icon and label.
|
||||
fn build_category_tile(label_text: &str, icon_name: &str, color_class: &str, active: bool) -> gtk::ToggleButton {
|
||||
let icon = gtk::Image::from_icon_name(icon_name);
|
||||
icon.set_pixel_size(24);
|
||||
|
||||
let label = gtk::Label::new(Some(label_text));
|
||||
label.set_ellipsize(gtk::pango::EllipsizeMode::End);
|
||||
|
||||
let inner = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Horizontal)
|
||||
.spacing(10)
|
||||
.halign(gtk::Align::Center)
|
||||
.build();
|
||||
inner.append(&icon);
|
||||
inner.append(&label);
|
||||
|
||||
let btn = gtk::ToggleButton::builder()
|
||||
.child(&inner)
|
||||
.active(active)
|
||||
.css_classes(["flat", "category-tile", color_class])
|
||||
.build();
|
||||
widgets::set_pointer_cursor(&btn);
|
||||
btn
|
||||
}
|
||||
|
||||
fn populate_categories(
|
||||
db: &Rc<Database>,
|
||||
category_box: >k::FlowBox,
|
||||
active_category: &Rc<RefCell<Option<String>>>,
|
||||
active_sort: &Rc<std::cell::Cell<CatalogSortOrder>>,
|
||||
flow_box: >k::FlowBox,
|
||||
search_entry: >k::SearchEntry,
|
||||
featured_section: >k::Box,
|
||||
@@ -732,26 +913,23 @@ fn populate_categories(
|
||||
return;
|
||||
}
|
||||
|
||||
let all_btn = gtk::ToggleButton::builder()
|
||||
.label(&i18n("All"))
|
||||
.active(true)
|
||||
.css_classes(["pill"])
|
||||
.build();
|
||||
category_box.insert(&all_btn, -1);
|
||||
let all_btn = build_category_tile(
|
||||
&i18n("All"), "view-grid-symbolic", "cat-accent", true,
|
||||
);
|
||||
category_box.append(&all_btn);
|
||||
|
||||
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.insert(&btn, -1);
|
||||
for (cat, _count) in categories.iter().take(12) {
|
||||
let (icon_name, color_class) = category_meta(cat);
|
||||
let btn = build_category_tile(cat, icon_name, color_class, false);
|
||||
category_box.append(&btn);
|
||||
buttons.borrow_mut().push(btn.clone());
|
||||
|
||||
let cat_str = cat.clone();
|
||||
let active_ref = active_category.clone();
|
||||
let sort_ref = active_sort.clone();
|
||||
let flow_ref = flow_box.clone();
|
||||
let search_ref = search_entry.clone();
|
||||
let db_ref = db.clone();
|
||||
@@ -771,8 +949,8 @@ fn populate_categories(
|
||||
featured_section_ref.set_visible(false);
|
||||
let query = search_ref.text().to_string();
|
||||
populate_grid(
|
||||
&db_ref, &query, Some(&cat_str), &flow_ref,
|
||||
&all_label_ref, &nav_ref, &toast_ref,
|
||||
&db_ref, &query, Some(&cat_str), sort_ref.get(),
|
||||
&flow_ref, &all_label_ref, &nav_ref, &toast_ref,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -780,6 +958,7 @@ fn populate_categories(
|
||||
|
||||
{
|
||||
let active_ref = active_category.clone();
|
||||
let sort_ref = active_sort.clone();
|
||||
let flow_ref = flow_box.clone();
|
||||
let search_ref = search_entry.clone();
|
||||
let db_ref = db.clone();
|
||||
@@ -799,8 +978,8 @@ fn populate_categories(
|
||||
featured_section_ref.set_visible(true);
|
||||
let query = search_ref.text().to_string();
|
||||
populate_grid(
|
||||
&db_ref, &query, None, &flow_ref,
|
||||
&all_label_ref, &nav_ref, &toast_ref,
|
||||
&db_ref, &query, None, sort_ref.get(),
|
||||
&flow_ref, &all_label_ref, &nav_ref, &toast_ref,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -69,6 +69,7 @@ pub fn build_dashboard_page(db: &Rc<Database>) -> adw::NavigationPage {
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.set_content(Some(&scrolled));
|
||||
widgets::apply_pointer_cursors(&toolbar);
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title("Dashboard")
|
||||
|
||||
@@ -197,6 +197,7 @@ pub fn build_detail_page(record: &AppImageRecord, db: &Rc<Database>) -> adw::Nav
|
||||
let toolbar = adw::ToolbarView::new();
|
||||
toolbar.add_top_bar(&header);
|
||||
toolbar.set_content(Some(&toast_overlay));
|
||||
widgets::apply_pointer_cursors(&toolbar);
|
||||
|
||||
adw::NavigationPage::builder()
|
||||
.title(name)
|
||||
|
||||
@@ -359,6 +359,7 @@ impl LibraryView {
|
||||
let toolbar_view = adw::ToolbarView::new();
|
||||
toolbar_view.add_top_bar(&header_bar);
|
||||
toolbar_view.set_content(Some(&content_box));
|
||||
widgets::apply_pointer_cursors(&toolbar_view);
|
||||
|
||||
let page = adw::NavigationPage::builder()
|
||||
.title("Driftwood")
|
||||
|
||||
@@ -14,6 +14,7 @@ pub fn show_preferences_dialog(parent: &impl IsA<gtk::Widget>) {
|
||||
|
||||
dialog.add(&build_general_page(&settings, &dialog));
|
||||
dialog.add(&build_updates_page(&settings));
|
||||
super::widgets::apply_pointer_cursors(&dialog);
|
||||
|
||||
dialog.present(Some(parent));
|
||||
}
|
||||
|
||||
@@ -197,6 +197,7 @@ pub fn build_updates_view(db: &Rc<Database>) -> adw::ToolbarView {
|
||||
let toolbar_view = adw::ToolbarView::new();
|
||||
toolbar_view.add_top_bar(&header);
|
||||
toolbar_view.set_content(Some(&toast_overlay));
|
||||
widgets::apply_pointer_cursors(&toolbar_view);
|
||||
|
||||
toolbar_view
|
||||
}
|
||||
|
||||
@@ -55,6 +55,37 @@ fn generate_letter_icon_css() -> String {
|
||||
css
|
||||
}
|
||||
|
||||
/// Set the pointer (hand) cursor on a widget, so it looks clickable on hover.
|
||||
pub fn set_pointer_cursor(widget: &impl IsA<gtk::Widget>) {
|
||||
widget.as_ref().set_cursor_from_name(Some("pointer"));
|
||||
}
|
||||
|
||||
/// Recursively walk a widget tree and set pointer cursor on all interactive elements.
|
||||
/// Call this on a view's root container after building it to cover buttons, switches,
|
||||
/// toggle buttons, activatable rows, and other clickable widgets.
|
||||
pub fn apply_pointer_cursors(widget: &impl IsA<gtk::Widget>) {
|
||||
let w = widget.as_ref();
|
||||
|
||||
let is_interactive = w.is::<gtk::Button>()
|
||||
|| w.is::<gtk::ToggleButton>()
|
||||
|| w.is::<adw::SplitButton>()
|
||||
|| w.is::<gtk::Switch>()
|
||||
|| w.is::<gtk::CheckButton>()
|
||||
|| w.is::<gtk::DropDown>()
|
||||
|| w.is::<gtk::Scale>()
|
||||
|| w.has_css_class("activatable");
|
||||
|
||||
if is_interactive {
|
||||
w.set_cursor_from_name(Some("pointer"));
|
||||
}
|
||||
|
||||
let mut child = w.first_child();
|
||||
while let Some(c) = child {
|
||||
apply_pointer_cursors(&c);
|
||||
child = c.next_sibling();
|
||||
}
|
||||
}
|
||||
|
||||
/// Create a status badge pill label with the given text and style class.
|
||||
/// Style classes: "success", "warning", "error", "info", "neutral"
|
||||
pub fn status_badge(text: &str, style_class: &str) -> gtk::Label {
|
||||
@@ -439,3 +470,163 @@ pub fn announce(container: &impl gtk::prelude::IsA<gtk::Widget>, text: &str) {
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Build a GTK widget tree from markdown text using pulldown-cmark.
|
||||
/// Returns a vertical Box containing formatted labels for each block element.
|
||||
pub fn build_markdown_view(markdown: &str) -> gtk::Box {
|
||||
use pulldown_cmark::{Event, Tag, TagEnd, Options, Parser};
|
||||
|
||||
let container = gtk::Box::builder()
|
||||
.orientation(gtk::Orientation::Vertical)
|
||||
.spacing(8)
|
||||
.build();
|
||||
|
||||
let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES;
|
||||
let parser = Parser::new_ext(markdown, options);
|
||||
|
||||
// Accumulate inline Pango markup, flush as labels on block boundaries
|
||||
let mut markup = String::new();
|
||||
let mut in_heading: Option<u8> = None;
|
||||
let mut in_code_block = false;
|
||||
let mut code_block_text = String::new();
|
||||
let mut list_depth: u32 = 0;
|
||||
let mut list_item_open = false;
|
||||
|
||||
let flush_label = |container: >k::Box, markup: &mut String, heading: Option<u8>| {
|
||||
let text = markup.trim().to_string();
|
||||
if text.is_empty() {
|
||||
markup.clear();
|
||||
return;
|
||||
}
|
||||
let label = gtk::Label::builder()
|
||||
.use_markup(true)
|
||||
.wrap(true)
|
||||
.wrap_mode(gtk::pango::WrapMode::WordChar)
|
||||
.xalign(0.0)
|
||||
.halign(gtk::Align::Fill)
|
||||
.selectable(true)
|
||||
.build();
|
||||
|
||||
label.set_markup(&text);
|
||||
|
||||
match heading {
|
||||
Some(1) => { label.add_css_class("title-1"); label.set_margin_top(12); }
|
||||
Some(2) => { label.add_css_class("title-2"); label.set_margin_top(10); }
|
||||
Some(3) => { label.add_css_class("title-3"); label.set_margin_top(8); }
|
||||
Some(4..=6) => { label.add_css_class("title-4"); label.set_margin_top(6); }
|
||||
_ => {}
|
||||
}
|
||||
container.append(&label);
|
||||
markup.clear();
|
||||
};
|
||||
|
||||
for event in parser {
|
||||
match event {
|
||||
Event::Start(Tag::Heading { level, .. }) => {
|
||||
flush_label(&container, &mut markup, None);
|
||||
in_heading = Some(level as u8);
|
||||
}
|
||||
Event::End(TagEnd::Heading(_)) => {
|
||||
let level = in_heading.take();
|
||||
flush_label(&container, &mut markup, level);
|
||||
}
|
||||
Event::Start(Tag::Paragraph) => {}
|
||||
Event::End(TagEnd::Paragraph) => {
|
||||
if !in_code_block {
|
||||
flush_label(&container, &mut markup, None);
|
||||
}
|
||||
}
|
||||
Event::Start(Tag::CodeBlock(_)) => {
|
||||
flush_label(&container, &mut markup, None);
|
||||
in_code_block = true;
|
||||
code_block_text.clear();
|
||||
}
|
||||
Event::End(TagEnd::CodeBlock) => {
|
||||
in_code_block = false;
|
||||
let code_label = gtk::Label::builder()
|
||||
.use_markup(true)
|
||||
.wrap(true)
|
||||
.wrap_mode(gtk::pango::WrapMode::WordChar)
|
||||
.xalign(0.0)
|
||||
.halign(gtk::Align::Fill)
|
||||
.selectable(true)
|
||||
.css_classes(["monospace", "card"])
|
||||
.margin_start(8)
|
||||
.margin_end(8)
|
||||
.build();
|
||||
// Escape for Pango markup inside the <tt> tag
|
||||
let escaped = glib::markup_escape_text(&code_block_text);
|
||||
code_label.set_markup(&format!("<tt>{}</tt>", escaped));
|
||||
container.append(&code_label);
|
||||
code_block_text.clear();
|
||||
}
|
||||
Event::Start(Tag::Strong) => markup.push_str("<b>"),
|
||||
Event::End(TagEnd::Strong) => markup.push_str("</b>"),
|
||||
Event::Start(Tag::Emphasis) => markup.push_str("<i>"),
|
||||
Event::End(TagEnd::Emphasis) => markup.push_str("</i>"),
|
||||
Event::Start(Tag::Strikethrough) => markup.push_str("<s>"),
|
||||
Event::End(TagEnd::Strikethrough) => markup.push_str("</s>"),
|
||||
Event::Start(Tag::Link { dest_url, .. }) => {
|
||||
markup.push_str(&format!("<a href=\"{}\">", glib::markup_escape_text(&dest_url)));
|
||||
}
|
||||
Event::End(TagEnd::Link) => markup.push_str("</a>"),
|
||||
Event::Start(Tag::List(_)) => {
|
||||
flush_label(&container, &mut markup, None);
|
||||
list_depth += 1;
|
||||
}
|
||||
Event::End(TagEnd::List(_)) => {
|
||||
flush_label(&container, &mut markup, None);
|
||||
list_depth = list_depth.saturating_sub(1);
|
||||
}
|
||||
Event::Start(Tag::Item) => {
|
||||
list_item_open = true;
|
||||
let indent = " ".repeat(list_depth.saturating_sub(1) as usize);
|
||||
markup.push_str(&format!("{} \u{2022} ", indent));
|
||||
}
|
||||
Event::End(TagEnd::Item) => {
|
||||
list_item_open = false;
|
||||
flush_label(&container, &mut markup, None);
|
||||
}
|
||||
Event::Code(code) => {
|
||||
markup.push_str(&format!("<tt>{}</tt>", glib::markup_escape_text(&code)));
|
||||
}
|
||||
Event::Text(text) => {
|
||||
if in_code_block {
|
||||
code_block_text.push_str(&text);
|
||||
} else {
|
||||
markup.push_str(&glib::markup_escape_text(&text));
|
||||
}
|
||||
}
|
||||
Event::SoftBreak => {
|
||||
if in_code_block {
|
||||
code_block_text.push('\n');
|
||||
} else if list_item_open {
|
||||
markup.push(' ');
|
||||
} else {
|
||||
markup.push(' ');
|
||||
}
|
||||
}
|
||||
Event::HardBreak => {
|
||||
if in_code_block {
|
||||
code_block_text.push('\n');
|
||||
} else {
|
||||
markup.push('\n');
|
||||
}
|
||||
}
|
||||
Event::Rule => {
|
||||
flush_label(&container, &mut markup, None);
|
||||
let sep = gtk::Separator::new(gtk::Orientation::Horizontal);
|
||||
sep.set_margin_top(8);
|
||||
sep.set_margin_bottom(8);
|
||||
container.append(&sep);
|
||||
}
|
||||
// Skip images, HTML, footnotes, etc.
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush any remaining text
|
||||
flush_label(&container, &mut markup, None);
|
||||
|
||||
container
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user