- 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
592 lines
19 KiB
Rust
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("&", "&")
|
|
.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::<Vec<&str>>().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("<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("& < > ""), "& < > \"");
|
|
}
|
|
|
|
#[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");
|
|
}
|
|
}
|