zcatui/src/accessibility.zig
reugenio c8316f2134 feat: zcatui v2.1 - 7 new widgets, innovations, and technical docs
New widgets (Phase 1-3):
- Spinner: 10 animation styles (dots, line, arc, pulse, etc.)
- Help: Keybinding display with categories
- Viewport: Content scrolling (static/scrollable)
- Progress: Multi-step progress with styles
- Markdown: Basic markdown rendering (headers, lists, code)
- DirectoryTree: File browser with icons and filters
- SyntaxHighlighter: Code highlighting (Zig, Rust, Python, etc.)

Innovation modules:
- testing.zig: Widget testing framework (harness, simulated input, benchmarks)
- theme_loader.zig: Theme hot-reload from JSON/KV files
- serialize.zig: State serialization, undo/redo stack
- accessibility.zig: A11y support (ARIA roles, screen reader, high contrast)

Layout improvements:
- Flex layout with JustifyContent and AlignItems

Documentation:
- TECHNICAL_REFERENCE.md: Comprehensive 1200+ line technical manual

Stats: 67 files, 34 widgets, 250+ tests

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2025-12-08 20:29:18 +01:00

591 lines
16 KiB
Zig

//! Accessibility Support for zcatui
//!
//! Provides accessibility features for terminal applications:
//! - Screen reader announcements (via terminal bell or OSC sequences)
//! - ARIA-like roles and labels for widgets
//! - High contrast mode support
//! - Reduced motion support
//! - Keyboard navigation helpers
//!
//! Example:
//! ```zig
//! const a11y = @import("accessibility.zig");
//!
//! // Announce a change to screen readers
//! a11y.announce("Selection changed to item 3 of 10");
//!
//! // Check accessibility preferences
//! if (a11y.prefersReducedMotion()) {
//! // Skip animations
//! }
//! ```
const std = @import("std");
const Style = @import("style.zig").Style;
const Color = @import("style.zig").Color;
const Theme = @import("theme.zig").Theme;
// ============================================================================
// Accessibility Roles
// ============================================================================
/// ARIA-like roles for widgets
pub const Role = enum {
/// No specific role
none,
/// Interactive button
button,
/// Checkbox (can be checked/unchecked)
checkbox,
/// Text input field
textbox,
/// Multi-line text input
textarea,
/// Selection list
listbox,
/// List item
option,
/// Menu container
menu,
/// Menu item
menuitem,
/// Tab panel
tablist,
/// Individual tab
tab,
/// Tab content panel
tabpanel,
/// Tree structure
tree,
/// Tree item
treeitem,
/// Table
grid,
/// Table row
row,
/// Table cell
gridcell,
/// Progress indicator
progressbar,
/// Slider control
slider,
/// Scrollbar
scrollbar,
/// Alert message
alert,
/// Dialog/modal
dialog,
/// Tooltip
tooltip,
/// Main application region
application,
/// Navigation region
navigation,
/// Main content region
main,
/// Status bar
status,
/// Banner/header
banner,
/// Informational region
region,
/// Get human-readable name for role
pub fn name(self: Role) []const u8 {
return switch (self) {
.none => "",
.button => "button",
.checkbox => "checkbox",
.textbox => "text field",
.textarea => "text area",
.listbox => "list",
.option => "list item",
.menu => "menu",
.menuitem => "menu item",
.tablist => "tab list",
.tab => "tab",
.tabpanel => "tab panel",
.tree => "tree",
.treeitem => "tree item",
.grid => "table",
.row => "row",
.gridcell => "cell",
.progressbar => "progress bar",
.slider => "slider",
.scrollbar => "scrollbar",
.alert => "alert",
.dialog => "dialog",
.tooltip => "tooltip",
.application => "application",
.navigation => "navigation",
.main => "main content",
.status => "status",
.banner => "header",
.region => "region",
};
}
};
/// Widget accessibility information
pub const AccessibleInfo = struct {
/// Role of the widget
role: Role = .none,
/// Human-readable label
label: ?[]const u8 = null,
/// Description for screen readers
description: ?[]const u8 = null,
/// Current value (for sliders, progress, etc.)
value: ?[]const u8 = null,
/// Minimum value (for sliders)
value_min: ?i64 = null,
/// Maximum value (for sliders)
value_max: ?i64 = null,
/// Current value as number (for sliders)
value_now: ?i64 = null,
/// Is this item selected?
selected: bool = false,
/// Is this item expanded? (for trees)
expanded: ?bool = null,
/// Is this item checked? (for checkboxes)
checked: ?bool = null,
/// Is this item disabled?
disabled: bool = false,
/// Is this required?
required: bool = false,
/// Is this read-only?
readonly: bool = false,
/// Position in set (1-based)
pos_in_set: ?u32 = null,
/// Size of set
set_size: ?u32 = null,
/// Level in hierarchy (for headings, tree items)
level: ?u32 = null,
/// Live region type
live: LiveRegion = .off,
/// Keyboard shortcut
shortcut: ?[]const u8 = null,
/// Format as announcement string
pub fn format(self: *const AccessibleInfo, buf: []u8) []const u8 {
var fbs = std.io.fixedBufferStream(buf);
const writer = fbs.writer();
// Label first
if (self.label) |label| {
writer.writeAll(label) catch {};
writer.writeAll(", ") catch {};
}
// Role
const role_name = self.role.name();
if (role_name.len > 0) {
writer.writeAll(role_name) catch {};
}
// State
if (self.disabled) {
writer.writeAll(", disabled") catch {};
}
if (self.checked) |checked| {
if (checked) {
writer.writeAll(", checked") catch {};
} else {
writer.writeAll(", not checked") catch {};
}
}
if (self.expanded) |expanded| {
if (expanded) {
writer.writeAll(", expanded") catch {};
} else {
writer.writeAll(", collapsed") catch {};
}
}
if (self.selected) {
writer.writeAll(", selected") catch {};
}
// Value
if (self.value) |value| {
writer.writeAll(", ") catch {};
writer.writeAll(value) catch {};
}
// Position
if (self.pos_in_set) |pos| {
if (self.set_size) |size| {
writer.print(", {} of {}", .{ pos, size }) catch {};
}
}
return fbs.getWritten();
}
};
/// Live region types for dynamic content
pub const LiveRegion = enum {
/// Not a live region
off,
/// Polite - announce when idle
polite,
/// Assertive - announce immediately
assertive,
};
// ============================================================================
// Screen Reader Announcements
// ============================================================================
/// Announcement queue for screen readers
pub const Announcer = struct {
/// Output buffer
output: std.ArrayListUnmanaged(u8),
/// Pending announcements
queue: std.ArrayListUnmanaged([]const u8),
allocator: std.mem.Allocator,
/// Use OSC sequences (for compatible terminals)
use_osc: bool = false,
pub fn init(allocator: std.mem.Allocator) Announcer {
return .{
.output = .{},
.queue = .{},
.allocator = allocator,
};
}
pub fn deinit(self: *Announcer) void {
self.output.deinit(self.allocator);
self.queue.deinit(self.allocator);
}
/// Queue an announcement
pub fn announce(self: *Announcer, message: []const u8) !void {
try self.queue.append(self.allocator, message);
}
/// Queue an assertive announcement (interrupt)
pub fn announceAssertive(self: *Announcer, message: []const u8) !void {
// Clear queue and add this message first
self.queue.clearRetainingCapacity();
try self.queue.append(self.allocator, message);
}
/// Generate output for pending announcements
pub fn flush(self: *Announcer) ![]const u8 {
self.output.clearRetainingCapacity();
for (self.queue.items) |message| {
if (self.use_osc) {
// OSC 52 or similar for screen reader
// Some terminals support OSC 99 for notifications
try self.output.appendSlice(self.allocator, "\x1b]99;");
try self.output.appendSlice(self.allocator, message);
try self.output.appendSlice(self.allocator, "\x07");
} else {
// Simple bell + message in title
try self.output.appendSlice(self.allocator, "\x1b]2;");
try self.output.appendSlice(self.allocator, message);
try self.output.appendSlice(self.allocator, "\x07");
}
}
self.queue.clearRetainingCapacity();
return self.output.items;
}
/// Check if there are pending announcements
pub fn hasPending(self: *const Announcer) bool {
return self.queue.items.len > 0;
}
};
/// Simple announce function (stateless)
pub fn makeAnnouncement(message: []const u8) [256]u8 {
var buf: [256]u8 = undefined;
var len: usize = 0;
// Set window title with message (works with screen readers)
const prefix = "\x1b]2;";
const suffix = "\x07";
@memcpy(buf[len..][0..prefix.len], prefix);
len += prefix.len;
const msg_len = @min(message.len, buf.len - len - suffix.len);
@memcpy(buf[len..][0..msg_len], message[0..msg_len]);
len += msg_len;
@memcpy(buf[len..][0..suffix.len], suffix);
len += suffix.len;
return buf;
}
// ============================================================================
// Accessibility Preferences
// ============================================================================
/// Detected accessibility preferences
pub const Preferences = struct {
/// User prefers reduced motion
reduced_motion: bool = false,
/// User prefers high contrast
high_contrast: bool = false,
/// User prefers no transparency
no_transparency: bool = false,
/// Screen reader detected
screen_reader: bool = false,
/// Minimum focus indicator size
min_focus_size: u16 = 2,
/// Detect preferences from environment
pub fn detect() Preferences {
var prefs = Preferences{};
// Check environment variables
if (std.posix.getenv("REDUCE_MOTION")) |_| {
prefs.reduced_motion = true;
}
if (std.posix.getenv("HIGH_CONTRAST")) |_| {
prefs.high_contrast = true;
}
if (std.posix.getenv("NO_TRANSPARENCY")) |_| {
prefs.no_transparency = true;
}
// Check for screen reader indicators
if (std.posix.getenv("SCREEN_READER")) |_| {
prefs.screen_reader = true;
}
if (std.posix.getenv("ORCA_PIDFILE")) |_| {
prefs.screen_reader = true;
}
if (std.posix.getenv("NVDA")) |_| {
prefs.screen_reader = true;
}
return prefs;
}
};
/// Global preferences (lazily initialized)
var global_prefs: ?Preferences = null;
/// Get accessibility preferences
pub fn getPreferences() Preferences {
if (global_prefs == null) {
global_prefs = Preferences.detect();
}
return global_prefs.?;
}
/// Check if user prefers reduced motion
pub fn prefersReducedMotion() bool {
return getPreferences().reduced_motion;
}
/// Check if user prefers high contrast
pub fn prefersHighContrast() bool {
return getPreferences().high_contrast;
}
/// Check if screen reader is detected
pub fn hasScreenReader() bool {
return getPreferences().screen_reader;
}
// ============================================================================
// High Contrast Theme
// ============================================================================
/// High contrast theme for accessibility
pub const high_contrast_theme = Theme{
.background = Color.black,
.foreground = Color.white,
.primary = Color.white,
.secondary = Color.white,
.success = Color.green,
.warning = Color.yellow,
.error_color = Color.red,
.info = Color.cyan,
.border = Color.white,
.text = Color.white,
.text_secondary = Color.white,
.selection_bg = Color.white,
.selection_fg = Color.black,
};
/// Get theme appropriate for accessibility settings
pub fn getAccessibleTheme(base_theme: Theme) Theme {
if (prefersHighContrast()) {
return high_contrast_theme;
}
return base_theme;
}
// ============================================================================
// Keyboard Navigation Helpers
// ============================================================================
/// Focus indicator style
pub const FocusIndicator = enum {
/// No visible indicator
none,
/// Box around focused element
box,
/// Underline focused element
underline,
/// Highlight background
highlight,
/// Bold text
bold,
};
/// Get focus style based on preferences
pub fn getFocusStyle(base: Style, indicator: FocusIndicator) Style {
const prefs = getPreferences();
var style = base;
switch (indicator) {
.none => {},
.box => {
// Use border - handled by widget
},
.underline => {
style = style.underline();
},
.highlight => {
if (prefs.high_contrast) {
style = style.bg(Color.white).fg(Color.black);
} else {
style = style.reverse();
}
},
.bold => {
style = style.bold();
},
}
return style;
}
// ============================================================================
// Skip Links (for keyboard navigation)
// ============================================================================
/// Skip link target for keyboard navigation
pub const SkipTarget = struct {
/// Target identifier
id: []const u8,
/// Human-readable label
label: []const u8,
/// Position in document
y: u16,
};
/// Skip link manager
pub const SkipLinks = struct {
targets: std.ArrayListUnmanaged(SkipTarget),
allocator: std.mem.Allocator,
pub fn init(allocator: std.mem.Allocator) SkipLinks {
return .{
.targets = .{},
.allocator = allocator,
};
}
pub fn deinit(self: *SkipLinks) void {
self.targets.deinit(self.allocator);
}
/// Register a skip target
pub fn register(self: *SkipLinks, id: []const u8, label: []const u8, y: u16) !void {
try self.targets.append(self.allocator, .{ .id = id, .label = label, .y = y });
}
/// Get next target from current position
pub fn next(self: *const SkipLinks, current_y: u16) ?SkipTarget {
for (self.targets.items) |target| {
if (target.y > current_y) {
return target;
}
}
return null;
}
/// Get previous target from current position
pub fn prev(self: *const SkipLinks, current_y: u16) ?SkipTarget {
var best: ?SkipTarget = null;
for (self.targets.items) |target| {
if (target.y < current_y) {
best = target;
}
}
return best;
}
/// Find target by id
pub fn find(self: *const SkipLinks, id: []const u8) ?SkipTarget {
for (self.targets.items) |target| {
if (std.mem.eql(u8, target.id, id)) {
return target;
}
}
return null;
}
};
// ============================================================================
// Tests
// ============================================================================
test "AccessibleInfo format" {
const info = AccessibleInfo{
.role = .button,
.label = "Submit",
.disabled = false,
};
var buf: [256]u8 = undefined;
const result = info.format(&buf);
try std.testing.expect(std.mem.indexOf(u8, result, "Submit") != null);
try std.testing.expect(std.mem.indexOf(u8, result, "button") != null);
}
test "AccessibleInfo with state" {
const info = AccessibleInfo{
.role = .checkbox,
.label = "Accept terms",
.checked = true,
};
var buf: [256]u8 = undefined;
const result = info.format(&buf);
try std.testing.expect(std.mem.indexOf(u8, result, "checked") != null);
}
test "Preferences detect" {
const prefs = Preferences.detect();
// Just verify it doesn't crash
_ = prefs.reduced_motion;
_ = prefs.high_contrast;
}
test "SkipLinks navigation" {
var links = SkipLinks.init(std.testing.allocator);
defer links.deinit();
try links.register("nav", "Navigation", 5);
try links.register("main", "Main content", 20);
try links.register("footer", "Footer", 50);
const next_target = links.next(10);
try std.testing.expect(next_target != null);
try std.testing.expectEqualStrings("main", next_target.?.id);
const prev_target = links.prev(30);
try std.testing.expect(prev_target != null);
try std.testing.expectEqualStrings("main", prev_target.?.id);
}