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:
lashman
2026-02-28 20:33:40 +02:00
parent f89aafca6a
commit 4b939f044a
16 changed files with 2394 additions and 417 deletions

View File

@@ -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

View File

@@ -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("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
.replace("&nbsp;", " ");
// 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;

View File

@@ -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: &gtk::FlowBox,
all_label: &gtk::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: &gtk::FlowBox,
active_category: &Rc<RefCell<Option<String>>>,
active_sort: &Rc<std::cell::Cell<CatalogSortOrder>>,
flow_box: &gtk::FlowBox,
search_entry: &gtk::SearchEntry,
featured_section: &gtk::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,
);
}
});

View File

@@ -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")

View File

@@ -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)

View File

@@ -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")

View File

@@ -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));
}

View File

@@ -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
}

View File

@@ -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: &gtk::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
}