use gtk::prelude::*; use gtk::accessible::Property as AccessibleProperty; 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. /// If `installed` is true, an "Installed" badge is shown on the card. pub fn build_catalog_tile(app: &CatalogApp, installed: bool) -> 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) - 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(); 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 (downloads + stars + version) - only if data exists let has_downloads = app.ocs_downloads.is_some_and(|d| d > 0); let has_stars = app.github_stars.is_some_and(|s| s > 0); let has_version = app.latest_version.is_some(); if has_downloads || 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(downloads) = app.ocs_downloads.filter(|&d| d > 0) { let dl_box = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(4) .build(); let dl_icon = gtk::Image::from_icon_name("folder-download-symbolic"); dl_icon.set_pixel_size(12); dl_icon.update_property(&[AccessibleProperty::Label("Downloads")]); dl_box.append(&dl_icon); let dl_label = gtk::Label::new(Some(&widgets::format_count(downloads))); dl_label.add_css_class("caption"); dl_label.add_css_class("dim-label"); dl_box.append(&dl_label); dl_box.set_tooltip_text(Some(&format!("{} downloads", downloads))); stats_row.append(&dl_box); } 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_icon.update_property(&[AccessibleProperty::Label("Stars")]); 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_icon.update_property(&[AccessibleProperty::Label("Version")]); 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); } } // Installed badge if installed { let installed_badge = widgets::status_badge("Installed", "success"); installed_badge.set_halign(gtk::Align::Start); installed_badge.set_margin_top(4); inner.append(&installed_badge); } card.append(&inner); let child = gtk::FlowBoxChild::builder() .child(&card) .build(); child.add_css_class("activatable"); widgets::set_pointer_cursor(&child); // Accessible label for screen readers let mut a11y_parts = vec![app.name.clone()]; if !plain.is_empty() { a11y_parts.push(plain.chars().take(80).collect()); } if let Some(downloads) = app.ocs_downloads.filter(|&d| d > 0) { a11y_parts.push(format!("{} downloads", downloads)); } if let Some(stars) = app.github_stars.filter(|&s| s > 0) { a11y_parts.push(format!("{} stars", stars)); } if let Some(ref cats) = app.categories { if let Some(cat) = cats.split(';').next().or_else(|| cats.split(',').next()) { let cat = cat.trim(); if !cat.is_empty() { a11y_parts.push(format!("category: {}", cat)); } } } if installed { a11y_parts.push("installed".to_string()); } child.update_property(&[AccessibleProperty::Label(&a11y_parts.join(", "))]); child } /// Build a compact list-row tile for the browse grid in list mode. /// Horizontal layout: icon (32px) | name | description snippet | stats. pub fn build_catalog_row(app: &CatalogApp, installed: bool) -> gtk::FlowBoxChild { let row = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(12) .halign(gtk::Align::Fill) .hexpand(true) .build(); row.add_css_class("card"); row.add_css_class("catalog-row"); let inner = gtk::Box::builder() .orientation(gtk::Orientation::Horizontal) .spacing(12) .margin_top(8) .margin_bottom(8) .margin_start(12) .margin_end(12) .hexpand(true) .build(); // Icon (32px) let icon = widgets::app_icon(None, &app.name, 32); icon.set_valign(gtk::Align::Center); inner.append(&icon); // Name let name_label = gtk::Label::builder() .label(&app.name) .css_classes(["heading"]) .ellipsize(gtk::pango::EllipsizeMode::End) .max_width_chars(18) .xalign(0.0) .width_chars(14) .build(); inner.append(&name_label); // Description (single line) 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 desc_label = gtk::Label::builder() .label(&plain) .css_classes(["dim-label"]) .ellipsize(gtk::pango::EllipsizeMode::End) .hexpand(true) .xalign(0.0) .build(); inner.append(&desc_label); // Stats (compact) if let Some(downloads) = app.ocs_downloads.filter(|&d| d > 0) { let dl_label = gtk::Label::builder() .label(&format!("{} dl", widgets::format_count(downloads))) .css_classes(["caption", "dim-label"]) .build(); inner.append(&dl_label); } else if let Some(stars) = app.github_stars.filter(|&s| s > 0) { let star_label = gtk::Label::builder() .label(&format!("{} stars", widgets::format_count(stars))) .css_classes(["caption", "dim-label"]) .build(); inner.append(&star_label); } // Installed badge if installed { let badge = widgets::status_badge("Installed", "success"); badge.set_valign(gtk::Align::Center); inner.append(&badge); } row.append(&inner); let child = gtk::FlowBoxChild::builder() .child(&row) .build(); child.add_css_class("activatable"); widgets::set_pointer_cursor(&child); // Accessible label for screen readers let mut a11y_parts = vec![app.name.clone()]; if !plain.is_empty() { a11y_parts.push(plain.chars().take(60).collect()); } if let Some(downloads) = app.ocs_downloads.filter(|&d| d > 0) { a11y_parts.push(format!("{} downloads", downloads)); } else if let Some(stars) = app.github_stars.filter(|&s| s > 0) { a11y_parts.push(format!("{} stars", stars)); } if installed { a11y_parts.push("installed".to_string()); } child.update_property(&[AccessibleProperty::Label(&a11y_parts.join(", "))]); 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"); widgets::set_pointer_cursor(&card); card.set_widget_name(&format!("featured-{}", app.id)); // Accessible label for screen readers let mut a11y_parts = vec![app.name.clone()]; if let Some(desc) = app.ocs_summary.as_deref() .filter(|d| !d.is_empty()) .or(app.github_description.as_deref().filter(|d| !d.is_empty())) { a11y_parts.push(strip_html(desc).chars().take(60).collect()); } if let Some(ref cats) = app.categories { if let Some(cat) = cats.split(';').next().or_else(|| cats.split(',').next()) { let cat = cat.trim(); if !cat.is_empty() { a11y_parts.push(format!("category: {}", cat)); } } } card.update_property(&[AccessibleProperty::Label(&a11y_parts.join(", "))]); // 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(); spinner.update_property(&[gtk::accessible::Property::Label("Loading screenshot")]); 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) - 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 + downloads/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(downloads) = app.ocs_downloads.filter(|&d| d > 0) { let dl_badge = widgets::status_badge_with_icon( "folder-download-symbolic", &widgets::format_count(downloads), "neutral", ); dl_badge.set_halign(gtk::Align::Start); badge_row.append(&dl_badge); } else 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 } /// 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; 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::>().join(" "); // Decode common HTML entities collapsed .replace("&", "&") .replace("<", "<") .replace(">", ">") .replace(""", "\"") .replace("'", "'") .replace("'", "'") } #[cfg(test)] mod tests { use super::*; #[test] fn test_strip_html_basic() { assert_eq!(strip_html("

Hello world

"), "Hello world"); } #[test] fn test_strip_html_nested() { assert_eq!( strip_html("

Hello bold world

"), "Hello bold world" ); } #[test] fn test_strip_html_entities() { assert_eq!(strip_html("& < > ""), "& < > \""); } #[test] fn test_strip_html_multiline() { let input = "

Line one

\n

Line two

"; assert_eq!(strip_html(input), "Line one Line two"); } #[test] fn test_strip_html_list() { let input = ""; 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"); } }