Add WCAG accessibility helpers: labeled badges, live announcements, copy button label
This commit is contained in:
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user