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));
|
let label = gtk::Label::new(Some(text));
|
||||||
label.add_css_class("status-badge");
|
label.add_css_class("status-badge");
|
||||||
label.add_css_class(style_class);
|
label.add_css_class(style_class);
|
||||||
|
label.set_accessible_role(gtk::AccessibleRole::Status);
|
||||||
label
|
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.
|
/// Create a badge showing integration status.
|
||||||
pub fn integration_badge(integrated: bool) -> gtk::Label {
|
pub fn integration_badge(integrated: bool) -> gtk::Label {
|
||||||
if integrated {
|
if integrated {
|
||||||
@@ -22,3 +45,132 @@ pub fn integration_badge(integrated: bool) -> gtk::Label {
|
|||||||
pub fn format_size(bytes: i64) -> String {
|
pub fn format_size(bytes: i64) -> String {
|
||||||
humansize::format_size(bytes as u64, humansize::BINARY)
|
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