From 19791168f37255a64e5c35c2bdadac18d70cdeb3 Mon Sep 17 00:00:00 2001 From: lashman Date: Fri, 27 Feb 2026 10:01:37 +0200 Subject: [PATCH] Add WCAG accessibility helpers: labeled badges, live announcements, copy button label --- src/ui/widgets.rs | 152 ++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 152 insertions(+) diff --git a/src/ui/widgets.rs b/src/ui/widgets.rs index dd20e34..238f979 100644 --- a/src/ui/widgets.rs +++ b/src/ui/widgets.rs @@ -6,9 +6,32 @@ pub fn status_badge(text: &str, style_class: &str) -> gtk::Label { let label = gtk::Label::new(Some(text)); label.add_css_class("status-badge"); label.add_css_class(style_class); + label.set_accessible_role(gtk::AccessibleRole::Status); label } +/// Create a status badge with a symbolic icon prefix for accessibility. +/// The badge contains both an icon and text so information is not color-dependent. +pub fn status_badge_with_icon(icon_name: &str, text: &str, style_class: &str) -> gtk::Box { + let hbox = gtk::Box::builder() + .orientation(gtk::Orientation::Horizontal) + .spacing(4) + .accessible_role(gtk::AccessibleRole::Status) + .build(); + hbox.add_css_class("status-badge-with-icon"); + hbox.add_css_class(style_class); + hbox.update_property(&[gtk::accessible::Property::Label(text)]); + + let icon = gtk::Image::from_icon_name(icon_name); + icon.set_pixel_size(12); + hbox.append(&icon); + + let label = gtk::Label::new(Some(text)); + hbox.append(&label); + + hbox +} + /// Create a badge showing integration status. pub fn integration_badge(integrated: bool) -> gtk::Label { if integrated { @@ -22,3 +45,132 @@ pub fn integration_badge(integrated: bool) -> gtk::Label { pub fn format_size(bytes: i64) -> String { humansize::format_size(bytes as u64, humansize::BINARY) } + +/// Build an app icon widget with letter-circle fallback. +/// If the icon_path exists and is loadable, show the real icon. +/// Otherwise, generate a colored circle with the first letter of the app name. +pub fn app_icon(icon_path: Option<&str>, app_name: &str, pixel_size: i32) -> gtk::Widget { + // Try to load from path + if let Some(icon_path) = icon_path { + let path = std::path::Path::new(icon_path); + if path.exists() { + if let Ok(texture) = gtk::gdk::Texture::from_filename(path) { + let image = gtk::Image::builder() + .pixel_size(pixel_size) + .build(); + image.set_paintable(Some(&texture)); + return image.upcast(); + } + } + } + + // Letter-circle fallback + build_letter_icon(app_name, pixel_size) +} + +/// Build a colored circle with the first letter of the name as a fallback icon. +fn build_letter_icon(name: &str, size: i32) -> gtk::Widget { + let letter = name + .chars() + .find(|c| c.is_alphanumeric()) + .unwrap_or('?') + .to_uppercase() + .next() + .unwrap_or('?'); + + // Pick a color based on the name hash for consistency + let color_index = name.bytes().fold(0u32, |acc, b| acc.wrapping_add(b as u32)) % 6; + let bg_color = match color_index { + 0 => "@accent_bg_color", + 1 => "@success_bg_color", + 2 => "@warning_bg_color", + 3 => "@error_bg_color", + 4 => "@accent_bg_color", + _ => "@success_bg_color", + }; + let fg_color = match color_index { + 0 => "@accent_fg_color", + 1 => "@success_fg_color", + 2 => "@warning_fg_color", + 3 => "@error_fg_color", + 4 => "@accent_fg_color", + _ => "@success_fg_color", + }; + + // Use a label styled as a circle with the letter + let label = gtk::Label::builder() + .label(&letter.to_string()) + .halign(gtk::Align::Center) + .valign(gtk::Align::Center) + .width_request(size) + .height_request(size) + .build(); + + // Apply inline CSS via a provider on the display + let css_provider = gtk::CssProvider::new(); + let unique_class = format!("letter-icon-{}", color_index); + let css = format!( + "label.{} {{ background: {}; color: {}; border-radius: 50%; min-width: {}px; min-height: {}px; font-size: {}px; font-weight: 700; }}", + unique_class, bg_color, fg_color, size, size, size * 4 / 10 + ); + css_provider.load_from_string(&css); + + if let Some(display) = gtk::gdk::Display::default() { + gtk::style_context_add_provider_for_display( + &display, + &css_provider, + gtk::STYLE_PROVIDER_PRIORITY_APPLICATION + 1, + ); + } + + label.add_css_class(&unique_class); + + label.upcast() +} + +/// Create a copy-to-clipboard button that shows a toast on success. +pub fn copy_button(text_to_copy: &str, toast_overlay: Option<&adw::ToastOverlay>) -> gtk::Button { + let btn = gtk::Button::builder() + .icon_name("edit-copy-symbolic") + .tooltip_text("Copy to clipboard") + .valign(gtk::Align::Center) + .build(); + btn.add_css_class("flat"); + btn.update_property(&[gtk::accessible::Property::Label("Copy to clipboard")]); + + let text = text_to_copy.to_string(); + let toast = toast_overlay.cloned(); + btn.connect_clicked(move |button| { + if let Some(display) = button.display().into() { + let clipboard = gtk::gdk::Display::clipboard(&display); + clipboard.set_text(&text); + if let Some(ref overlay) = toast { + overlay.add_toast(adw::Toast::new("Copied to clipboard")); + } + } + }); + btn +} + +/// Create a screen-reader live region announcement. +/// Inserts a hidden label with AccessibleRole::Alert into the given container, +/// which causes AT-SPI to announce the text to screen readers. +/// The label auto-removes after a short delay. +pub fn announce(container: &impl gtk::prelude::IsA, text: &str) { + let label = gtk::Label::builder() + .label(text) + .visible(false) + .accessible_role(gtk::AccessibleRole::Alert) + .build(); + label.update_property(&[gtk::accessible::Property::Label(text)]); + + if let Some(box_widget) = container.dynamic_cast_ref::() { + box_widget.append(&label); + label.set_visible(true); + let label_clone = label.clone(); + let box_clone = box_widget.clone(); + glib::timeout_add_local_once(std::time::Duration::from_millis(500), move || { + box_clone.remove(&label_clone); + }); + } +}