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>
591 lines
16 KiB
Zig
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);
|
|
}
|