Add WCAG accessibility helpers: labeled badges, live announcements, copy button label

This commit is contained in:
lashman
2026-02-27 10:01:37 +02:00
parent df533dda0a
commit 19791168f3

View File

@@ -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<gtk::Widget>, 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::<gtk::Box>() {
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);
});
}
}