//! 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); }