diff --git a/packages/core/docs/accessibility.md b/packages/core/docs/accessibility.md new file mode 100644 index 000000000..b58595b31 --- /dev/null +++ b/packages/core/docs/accessibility.md @@ -0,0 +1,112 @@ +# Accessibility API + +OpenTUI provides accessibility support for screen readers via cross-platform text-to-speech. + +## Quick Start + +```typescript +import { BoxRenderable, getAccessibilityManager } from "@opentui/core" + +// Enable accessibility +const accessibility = getAccessibilityManager() +accessibility.setEnabled(true) + +// Create accessible component +const button = new BoxRenderable(renderer, { + accessibilityRole: "button", + accessibilityLabel: "Submit Form", + accessibilityHint: "Press Enter to submit", +}) + +// Announcements +accessibility.announce("Form submitted successfully", "polite") +``` + +## Accessibility Properties + +| Property | Type | Description | +| --------------------- | ------------------- | ----------------------------------------- | +| `accessibilityRole` | `AccessibilityRole` | Semantic role (button, text, input, etc.) | +| `accessibilityLabel` | `string` | Human-readable name for screen readers | +| `accessibilityValue` | `string \| number` | Current value (for inputs, sliders) | +| `accessibilityHint` | `string` | Additional context about the element | +| `accessibilityHidden` | `boolean` | Hide from assistive technologies | +| `accessibilityLive` | `AccessibilityLive` | Live region update behavior | + +## Roles + +```typescript +type AccessibilityRole = + | "none" + | "button" + | "text" + | "input" + | "checkbox" + | "radio" + | "list" + | "listItem" + | "menu" + | "menuItem" + | "dialog" + | "alert" + | "progressbar" + | "slider" + | "scrollbar" + | "group" +``` + +## Live Regions + +Control how dynamic content changes are announced: + +- `"off"` - Don't announce changes +- `"polite"` - Announce when idle +- `"assertive"` - Announce immediately + +## AccessibilityManager + +```typescript +const manager = getAccessibilityManager() + +// Enable/disable +manager.setEnabled(true) +manager.enabled // boolean + +// Announcements +manager.announce("Message", "polite" | "assertive") + +// Focus tracking +manager.setFocused(renderable) // Announces element name + role + +// Events +manager.on("accessibility-event", (event) => { + console.log(event.type, event.targetId) +}) +``` + +## Platform Support + +| Platform | TTS Method | Screen Reader | +| -------- | ---------- | -------------- | +| Linux | spd-say | Orca | +| Windows | SAPI | NVDA, Narrator | +| macOS | say | VoiceOver | + +### Linux Requirements + +- `speech-dispatcher` installed +- `espeak-ng` for text-to-speech +- Configure: `DefaultModule espeak-ng` in `/etc/speech-dispatcher/speechd.conf` + +### Windows Requirements + +- PowerShell (included with Windows) +- System.Speech assembly (included with .NET Framework) + +### macOS Requirements + +- None - `say` is built into macOS + +## Example + +See `examples/accessibility-demo.ts` for a complete demonstration. diff --git a/packages/core/src/Renderable.ts b/packages/core/src/Renderable.ts index e0700a2fe..831223fef 100644 --- a/packages/core/src/Renderable.ts +++ b/packages/core/src/Renderable.ts @@ -48,6 +48,35 @@ export enum RenderableEvents { BLURRED = "blurred", } +export type AccessibilityRole = + | "none" + | "button" + | "text" + | "input" + | "checkbox" + | "radio" + | "list" + | "listItem" + | "menu" + | "menuItem" + | "dialog" + | "alert" + | "progressbar" + | "slider" + | "scrollbar" + | "group" + +export type AccessibilityLive = "off" | "polite" | "assertive" + +export interface AccessibilityOptions { + accessibilityRole?: AccessibilityRole + accessibilityLabel?: string + accessibilityValue?: string | number + accessibilityHint?: string + accessibilityHidden?: boolean + accessibilityLive?: AccessibilityLive +} + export interface Position { top?: number | "auto" | `${number}%` right?: number | "auto" | `${number}%` @@ -91,7 +120,9 @@ export interface LayoutOptions extends BaseRenderableOptions { enableLayout?: boolean } -export interface RenderableOptions extends Partial { +export interface RenderableOptions + extends Partial, + AccessibilityOptions { width?: number | "auto" | `${number}%` height?: number | "auto" | `${number}%` zIndex?: number @@ -235,6 +266,14 @@ export abstract class Renderable extends BaseRenderable { protected _opacity: number = 1.0 private _flexShrink: number = 1 + // Accessibility properties + protected _accessibilityRole: AccessibilityRole = "none" + protected _accessibilityLabel: string | undefined = undefined + protected _accessibilityValue: string | number | undefined = undefined + protected _accessibilityHint: string | undefined = undefined + protected _accessibilityHidden: boolean = false + protected _accessibilityLive: AccessibilityLive = "off" + private renderableMapById: Map = new Map() protected _childrenInLayoutOrder: Renderable[] = [] protected _childrenInZIndexOrder: Renderable[] = [] @@ -278,6 +317,14 @@ export abstract class Renderable extends BaseRenderable { this._liveCount = this._live && this._visible ? 1 : 0 this._opacity = options.opacity !== undefined ? Math.max(0, Math.min(1, options.opacity)) : 1.0 + // Initialize accessibility options + this._accessibilityRole = options.accessibilityRole ?? "none" + this._accessibilityLabel = options.accessibilityLabel + this._accessibilityValue = options.accessibilityValue + this._accessibilityHint = options.accessibilityHint + this._accessibilityHidden = options.accessibilityHidden ?? false + this._accessibilityLive = options.accessibilityLive ?? "off" + // TODO: use a global yoga config this.yogaNode = Yoga.Node.create(yogaConfig) this.yogaNode.setDisplay(this._visible ? Display.Flex : Display.None) @@ -352,6 +399,78 @@ export abstract class Renderable extends BaseRenderable { } } + // Accessibility getters and setters + public get accessibilityRole(): AccessibilityRole { + return this._accessibilityRole + } + + public set accessibilityRole(value: AccessibilityRole) { + if (this._accessibilityRole !== value) { + this._accessibilityRole = value + this.onAccessibilityChange() + } + } + + public get accessibilityLabel(): string | undefined { + return this._accessibilityLabel + } + + public set accessibilityLabel(value: string | undefined) { + if (this._accessibilityLabel !== value) { + this._accessibilityLabel = value + this.onAccessibilityChange() + } + } + + public get accessibilityValue(): string | number | undefined { + return this._accessibilityValue + } + + public set accessibilityValue(value: string | number | undefined) { + if (this._accessibilityValue !== value) { + this._accessibilityValue = value + this.onAccessibilityChange() + } + } + + public get accessibilityHint(): string | undefined { + return this._accessibilityHint + } + + public set accessibilityHint(value: string | undefined) { + if (this._accessibilityHint !== value) { + this._accessibilityHint = value + this.onAccessibilityChange() + } + } + + public get accessibilityHidden(): boolean { + return this._accessibilityHidden + } + + public set accessibilityHidden(value: boolean) { + if (this._accessibilityHidden !== value) { + this._accessibilityHidden = value + this.onAccessibilityChange() + } + } + + public get accessibilityLive(): AccessibilityLive { + return this._accessibilityLive + } + + public set accessibilityLive(value: AccessibilityLive) { + if (this._accessibilityLive !== value) { + this._accessibilityLive = value + this.onAccessibilityChange() + } + } + + protected onAccessibilityChange(): void { + // Hook for subclasses and future accessibility manager integration + // Will be used to notify platform accessibility APIs of changes + } + public hasSelection(): boolean { return false } diff --git a/packages/core/src/examples/accessibility-demo.ts b/packages/core/src/examples/accessibility-demo.ts new file mode 100644 index 000000000..3ceda0021 --- /dev/null +++ b/packages/core/src/examples/accessibility-demo.ts @@ -0,0 +1,260 @@ +// Accessibility Demo for OpenTUI +// Demonstrates how to use accessibility properties for screen reader support + +import { + createCliRenderer, + BoxRenderable, + TextRenderable, + InputRenderable, + type CliRenderer, + type KeyEvent, +} from "../index" +import { getAccessibilityManager } from "../lib/AccessibilityManager" +import { setupCommonDemoKeys } from "./lib/standalone-keys" + +let container: BoxRenderable | null = null +let progressLabel: TextRenderable | null = null +let nameInput: InputRenderable | null = null +let emailInput: InputRenderable | null = null +let keyboardHandler: ((key: KeyEvent) => void) | null = null + +const inputElements: InputRenderable[] = [] +let activeInputIndex = 0 + +function navigateToInput(index: number): void { + const currentActive = inputElements[activeInputIndex] + currentActive?.blur() + + activeInputIndex = Math.max(0, Math.min(index, inputElements.length - 1)) + const newActive = inputElements[activeInputIndex] + newActive?.focus() + + // Notify accessibility manager of focus change + const accessibility = getAccessibilityManager() + if (newActive) { + accessibility.setFocused(newActive) + } +} + +export function run(renderer: CliRenderer): void { + renderer.setBackgroundColor("#001122") + + // Enable accessibility support + const accessibility = getAccessibilityManager() + accessibility.setEnabled(true) + + // Main container + container = new BoxRenderable(renderer, { + id: "accessibility-container", + zIndex: 10, + }) + + // Header + const header = new TextRenderable(renderer, { + content: "🔊 Accessibility Demo", + position: "absolute", + left: 2, + top: 1, + accessibilityRole: "text", + accessibilityLabel: "Page Header: Accessibility Demo", + }) + container.add(header) + + // Description + const description = new TextRenderable(renderer, { + content: "This demo showcases accessibility properties for screen readers.", + position: "absolute", + left: 2, + top: 2, + accessibilityRole: "text", + }) + container.add(description) + + // Section header + const sectionHeader = new TextRenderable(renderer, { + content: "Interactive Controls:", + position: "absolute", + left: 2, + top: 4, + accessibilityRole: "text", + }) + container.add(sectionHeader) + + // Name label and input + const nameLabel = new TextRenderable(renderer, { + content: "Name:", + position: "absolute", + left: 2, + top: 6, + accessibilityRole: "text", + }) + container.add(nameLabel) + + nameInput = new InputRenderable(renderer, { + id: "name-input", + position: "absolute", + left: 10, + top: 6, + width: 40, + height: 1, + backgroundColor: "#002244", + textColor: "#FFFFFF", + placeholder: "Enter your name...", + placeholderColor: "#666666", + cursorColor: "#FFFF00", + accessibilityRole: "input", + accessibilityLabel: "Name input field", + accessibilityHint: "Type your full name here", + }) + container.add(nameInput) + inputElements.push(nameInput) + + // Email label and input + const emailLabel = new TextRenderable(renderer, { + content: "Email:", + position: "absolute", + left: 2, + top: 8, + accessibilityRole: "text", + }) + container.add(emailLabel) + + emailInput = new InputRenderable(renderer, { + id: "email-input", + position: "absolute", + left: 10, + top: 8, + width: 40, + height: 1, + backgroundColor: "#002244", + textColor: "#FFFFFF", + placeholder: "Enter your email...", + placeholderColor: "#666666", + cursorColor: "#FFFF00", + accessibilityRole: "input", + accessibilityLabel: "Email input field", + accessibilityHint: "Type your email address here", + }) + container.add(emailInput) + inputElements.push(emailInput) + + // Buttons + const submitButton = new TextRenderable(renderer, { + content: "[Submit]", + position: "absolute", + left: 2, + top: 10, + accessibilityRole: "button", + accessibilityLabel: "Submit Form", + accessibilityHint: "Press Enter to submit", + }) + container.add(submitButton) + + const cancelButton = new TextRenderable(renderer, { + content: "[Cancel]", + position: "absolute", + left: 12, + top: 10, + accessibilityRole: "button", + accessibilityLabel: "Cancel", + accessibilityHint: "Press Escape to cancel", + }) + container.add(cancelButton) + + // Progress + progressLabel = new TextRenderable(renderer, { + content: "Form Completion: 0%", + position: "absolute", + left: 2, + top: 12, + accessibilityRole: "text", + accessibilityLabel: "Form completion progress", + accessibilityValue: "0 percent", + accessibilityLive: "polite", + }) + container.add(progressLabel) + + // Instructions + const instructions = new TextRenderable(renderer, { + content: "Press Tab to navigate, Enter to submit, Escape to exit", + position: "absolute", + left: 2, + top: 14, + fg: "#888888", + accessibilityRole: "text", + accessibilityLive: "off", + }) + container.add(instructions) + + // Update progress when inputs change + let filledFields = 0 + const updateProgress = () => { + const percentage = Math.round((filledFields / 2) * 100) + if (progressLabel) { + progressLabel.content = `Form Completion: ${percentage}%` + progressLabel.accessibilityValue = `${percentage} percent` + } + + if (percentage === 100) { + accessibility.announce("Form is complete! You can now submit.", "polite") + } + } + + nameInput.on("change", () => { + const hasValue = nameInput!.value.length > 0 + filledFields = hasValue ? filledFields + 1 : Math.max(0, filledFields - 1) + updateProgress() + }) + + emailInput.on("change", () => { + const hasValue = emailInput!.value.length > 0 + filledFields = hasValue ? filledFields + 1 : Math.max(0, filledFields - 1) + updateProgress() + }) + + // Keyboard handling + keyboardHandler = (key: KeyEvent) => { + if (key.name === "tab") { + if (key.shift) { + navigateToInput(activeInputIndex - 1) + } else { + navigateToInput(activeInputIndex + 1) + } + } else if (key.name === "return" || key.name === "enter") { + // Form submitted - announce to screen reader + accessibility.announce("Form submitted successfully!", "assertive") + } else if (key.name === "escape") { + process.exit(0) + } + } + + renderer.keyInput.on("keypress", keyboardHandler) + renderer.root.add(container) + + // Focus first input and announce for accessibility + nameInput.focus() + accessibility.setFocused(nameInput) +} + +export function destroy(renderer: CliRenderer): void { + if (keyboardHandler) { + renderer.keyInput.off("keypress", keyboardHandler) + keyboardHandler = null + } + container?.destroyRecursively() + container = null + progressLabel = null + nameInput = null + emailInput = null + inputElements.length = 0 + activeInputIndex = 0 +} + +if (import.meta.main) { + const renderer = await createCliRenderer({ + targetFps: 30, + exitOnCtrlC: true, + }) + run(renderer) + setupCommonDemoKeys(renderer) +} diff --git a/packages/core/src/lib/AccessibilityManager.ts b/packages/core/src/lib/AccessibilityManager.ts new file mode 100644 index 000000000..e780a6721 --- /dev/null +++ b/packages/core/src/lib/AccessibilityManager.ts @@ -0,0 +1,257 @@ +import { EventEmitter } from "events" +import type { Renderable, AccessibilityRole, AccessibilityLive } from "../Renderable" + +export interface AccessibilityNode { + id: number + role: AccessibilityRole + name: string | undefined + value: string | number | undefined + hint: string | undefined + hidden: boolean + live: AccessibilityLive + bounds: { x: number; y: number; width: number; height: number } + focused: boolean + parentId: number | null + children: number[] +} + +export enum AccessibilityEventType { + FOCUS_CHANGED = "focus-changed", + VALUE_CHANGED = "value-changed", + STRUCTURE_CHANGED = "structure-changed", + ANNOUNCEMENT = "announcement", +} + +export interface AccessibilityEvent { + type: AccessibilityEventType + targetId: number + data?: unknown +} + +export class AccessibilityManager extends EventEmitter { + private _enabled: boolean = false + private _nodes: Map = new Map() + private _focusedId: number | null = null + + constructor() { + super() + } + + public get enabled(): boolean { + return this._enabled + } + + public setEnabled(enabled: boolean): void { + if (this._enabled === enabled) return + + this._enabled = enabled + + if (enabled) { + this.initialize() + } else { + this.cleanup() + } + + this.emit("enabled-changed", enabled) + } + + private initialize(): void { + // Future: Initialize platform-specific accessibility provider + // Windows: Create hidden HWND, register UIA provider + // macOS: Create NSAccessibilityElement hierarchy + // Linux: Connect to AT-SPI2 D-Bus + } + + private cleanup(): void { + // Future: Cleanup platform-specific resources + this._nodes.clear() + this._focusedId = null + } + + public buildNodeFromRenderable(renderable: Renderable): AccessibilityNode { + const children = renderable.getChildren().map((child) => child.num) + + return { + id: renderable.num, + role: renderable.accessibilityRole, + name: renderable.accessibilityLabel, + value: renderable.accessibilityValue, + hint: renderable.accessibilityHint, + hidden: renderable.accessibilityHidden, + live: renderable.accessibilityLive, + bounds: { + x: renderable.x, + y: renderable.y, + width: renderable.width, + height: renderable.height, + }, + focused: renderable.focused, + parentId: renderable.parent?.num ?? null, + children, + } + } + + public updateNode(renderable: Renderable): void { + if (!this._enabled) return + + const node = this.buildNodeFromRenderable(renderable) + this._nodes.set(node.id, node) + + // Emit structure change if this is a new node + this.emit("node-updated", node) + } + + public removeNode(id: number): void { + if (!this._enabled) return + + this._nodes.delete(id) + this.emit("node-removed", id) + } + + public setFocused(renderable: Renderable | null): void { + if (!this._enabled) return + + const newFocusedId = renderable?.num ?? null + + if (this._focusedId !== newFocusedId) { + this._focusedId = newFocusedId + + this.raiseEvent({ + type: AccessibilityEventType.FOCUS_CHANGED, + targetId: newFocusedId ?? 0, + }) + + // Speak the focused element for TUI accessibility + if (renderable) { + const label = renderable.accessibilityLabel || "" + const role = renderable.accessibilityRole || "element" + const announcement = label ? `${label}, ${role}` : role + this.speakForPlatform(announcement) + } + } + } + + public raiseEvent(event: AccessibilityEvent): void { + if (!this._enabled) return + + this.emit("accessibility-event", event) + + // Future: Forward to platform-specific accessibility API + // Windows: UiaRaiseAutomationEvent + // macOS: NSAccessibilityPostNotification + // Linux: AT-SPI2 D-Bus event emission + } + + public announce(message: string, priority: "polite" | "assertive" = "polite"): void { + if (!this._enabled) return + + this.raiseEvent({ + type: AccessibilityEventType.ANNOUNCEMENT, + targetId: 0, + data: { message, priority }, + }) + + // Speak directly for TUI accessibility + this.speakForPlatform(message, priority) + } + + private speakForPlatform(message: string, priority: "polite" | "assertive" = "polite"): void { + if (process.platform === "linux") { + this.speakViaSpdSay(message, priority) + } else if (process.platform === "win32") { + this.speakViaSapi(message) + } else if (process.platform === "darwin") { + this.speakViaSay(message) + } + } + + private speakViaSpdSay(message: string, priority: "polite" | "assertive"): void { + try { + // Use Bun.spawn to run spd-say asynchronously + // spd-say priority: important, message, text, notification, progress + const args = priority === "assertive" ? ["-P", "important", message] : [message] + Bun.spawn(["spd-say", ...args], { + stdout: "ignore", + stderr: "ignore", + }) + } catch { + // spd-say not available, silently ignore + } + } + + private speakViaSapi(message: string): void { + try { + // Use PowerShell to speak via Windows SAPI + const escapedMessage = message.replace(/'/g, "''").replace(/"/g, '`"') + Bun.spawn( + [ + "powershell", + "-NoProfile", + "-Command", + `Add-Type -AssemblyName System.Speech; (New-Object System.Speech.Synthesis.SpeechSynthesizer).Speak('${escapedMessage}')`, + ], + { + stdout: "ignore", + stderr: "ignore", + }, + ) + } catch { + // PowerShell/SAPI not available, silently ignore + } + } + + private speakViaSay(message: string): void { + try { + // Use macOS say command + const escapedMessage = message.replace(/'/g, "'\\''") + Bun.spawn(["say", escapedMessage], { + stdout: "ignore", + stderr: "ignore", + }) + } catch { + // say command not available, silently ignore + } + } + + public getNode(id: number): AccessibilityNode | undefined { + return this._nodes.get(id) + } + + public getAllNodes(): AccessibilityNode[] { + return Array.from(this._nodes.values()) + } + + public getFocusedNode(): AccessibilityNode | undefined { + if (this._focusedId === null) return undefined + return this._nodes.get(this._focusedId) + } + + public buildTree(root: Renderable): AccessibilityNode[] { + const nodes: AccessibilityNode[] = [] + + const traverse = (renderable: Renderable) => { + if (renderable.accessibilityHidden) return + + const node = this.buildNodeFromRenderable(renderable) + nodes.push(node) + this._nodes.set(node.id, node) + + for (const child of renderable.getChildren()) { + traverse(child as Renderable) + } + } + + traverse(root) + return nodes + } +} + +// Singleton instance for global accessibility management +let globalAccessibilityManager: AccessibilityManager | null = null + +export function getAccessibilityManager(): AccessibilityManager { + if (!globalAccessibilityManager) { + globalAccessibilityManager = new AccessibilityManager() + } + return globalAccessibilityManager +} diff --git a/packages/core/src/lib/index.ts b/packages/core/src/lib/index.ts index 71496bbcb..0117abaa0 100644 --- a/packages/core/src/lib/index.ts +++ b/packages/core/src/lib/index.ts @@ -16,3 +16,4 @@ export * from "./tree-sitter" export * from "./data-paths" export * from "./extmarks" export * from "./terminal-palette" +export * from "./AccessibilityManager" diff --git a/packages/core/src/tests/accessibility.test.ts b/packages/core/src/tests/accessibility.test.ts new file mode 100644 index 000000000..1b8e83dad --- /dev/null +++ b/packages/core/src/tests/accessibility.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it, beforeEach } from "bun:test" +import { + AccessibilityManager, + getAccessibilityManager, + AccessibilityEventType, + type AccessibilityNode, +} from "../lib/AccessibilityManager" +import type { AccessibilityRole, AccessibilityLive } from "../Renderable" + +describe("AccessibilityManager", () => { + let manager: AccessibilityManager + + beforeEach(() => { + manager = new AccessibilityManager() + }) + + describe("enabled state", () => { + it("should be disabled by default", () => { + expect(manager.enabled).toBe(false) + }) + + it("should enable when setEnabled(true) is called", () => { + manager.setEnabled(true) + expect(manager.enabled).toBe(true) + }) + + it("should disable when setEnabled(false) is called", () => { + manager.setEnabled(true) + manager.setEnabled(false) + expect(manager.enabled).toBe(false) + }) + + it("should emit enabled-changed event", () => { + let emittedValue = false + manager.on("enabled-changed", (enabled: boolean) => { + emittedValue = enabled + }) + + manager.setEnabled(true) + expect(emittedValue).toBe(true) + }) + }) + + describe("node management", () => { + it("should return undefined for unknown node", () => { + expect(manager.getNode(999)).toBeUndefined() + }) + + it("should return all nodes as empty array initially", () => { + expect(manager.getAllNodes()).toEqual([]) + }) + }) + + describe("singleton accessor", () => { + it("should return same instance", () => { + const m1 = getAccessibilityManager() + const m2 = getAccessibilityManager() + expect(m1).toBe(m2) + }) + }) + + describe("announce", () => { + it("should emit announcement event when enabled", () => { + manager.setEnabled(true) + let eventReceived = false + + manager.on("accessibility-event", (event) => { + if (event.type === AccessibilityEventType.ANNOUNCEMENT) { + eventReceived = true + expect(event.data).toEqual({ message: "Test message", priority: "polite" }) + } + }) + + manager.announce("Test message") + expect(eventReceived).toBe(true) + }) + + it("should not emit when disabled", () => { + let eventReceived = false + manager.on("accessibility-event", () => { + eventReceived = true + }) + + manager.announce("Test message") + expect(eventReceived).toBe(false) + }) + }) +}) + +describe("Accessibility Types", () => { + it("should have valid AccessibilityRole values", () => { + const roles: AccessibilityRole[] = [ + "none", + "button", + "text", + "input", + "checkbox", + "radio", + "list", + "listItem", + "menu", + "menuItem", + "dialog", + "alert", + "progressbar", + "slider", + "scrollbar", + "group", + ] + + expect(roles.length).toBe(16) + }) + + it("should have valid AccessibilityLive values", () => { + const liveValues: AccessibilityLive[] = ["off", "polite", "assertive"] + expect(liveValues.length).toBe(3) + }) +})