Files
driftwood/src/ui/catalog_tile.rs
lashman 7e55d5796f Add WCAG 2.2 AAA compliance and automated AT-SPI audit tool
- Bring all UI widgets to WCAG 2.2 AAA conformance across all views
- Add accessible labels, roles, descriptions, and announcements
- Bump focus outlines to 3px, target sizes to 44px AAA minimum
- Fix announce()/announce_result() to walk widget tree via parent()
- Add AT-SPI accessibility audit script (tools/a11y-audit.py) that
  checks SC 4.1.2, 1.1.1, 1.3.1, 2.1.1, 2.5.5, 2.5.8, 2.4.8,
  2.4.9, 2.4.10, 2.1.3 with JSON report output for CI
- Clean up project structure, archive old plan documents
2026-03-01 12:44:21 +02:00

592 lines
19 KiB
Rust

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("&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;
for ch in html.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
_ if !in_tag => result.push(ch),
_ => {}
}
}
// Collapse whitespace
let collapsed: String = result.split_whitespace().collect::<Vec<&str>>().join(" ");
// Decode common HTML entities
collapsed
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&#39;", "'")
.replace("&apos;", "'")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_strip_html_basic() {
assert_eq!(strip_html("<p>Hello world</p>"), "Hello world");
}
#[test]
fn test_strip_html_nested() {
assert_eq!(
strip_html("<p>Hello <b>bold</b> world</p>"),
"Hello bold world"
);
}
#[test]
fn test_strip_html_entities() {
assert_eq!(strip_html("&amp; &lt; &gt; &quot;"), "& < > \"");
}
#[test]
fn test_strip_html_multiline() {
let input = "<p>Line one</p>\n<p>Line two</p>";
assert_eq!(strip_html(input), "Line one Line two");
}
#[test]
fn test_strip_html_list() {
let input = "<ul>\n <li>Item 1</li>\n <li>Item 2</li>\n</ul>";
assert_eq!(strip_html(input), "Item 1 Item 2");
}
#[test]
fn test_strip_html_plain_text() {
assert_eq!(strip_html("No HTML here"), "No HTML here");
}
}