diff --git a/packages/runtime-tests/integration/composables/use-screen-reader.test.tsx b/packages/runtime-tests/integration/composables/use-screen-reader.test.tsx new file mode 100644 index 0000000..8daa820 --- /dev/null +++ b/packages/runtime-tests/integration/composables/use-screen-reader.test.tsx @@ -0,0 +1,14 @@ +import { defineComponent } from "vue"; +import { expect, test } from "vite-plus/test"; +import { render } from "@vue-tui/testing"; +import { Text, useIsScreenReaderEnabled } from "@vue-tui/runtime"; + +test("useIsScreenReaderEnabled returns false by default", async () => { + let result = false; + const App = defineComponent(() => { + result = useIsScreenReaderEnabled(); + return () => sr test; + }); + await render(App); + expect(result).toBe(false); +}); diff --git a/packages/runtime-tests/integration/public-api.test.ts b/packages/runtime-tests/integration/public-api.test.ts index 3cef2ba..df721f1 100644 --- a/packages/runtime-tests/integration/public-api.test.ts +++ b/packages/runtime-tests/integration/public-api.test.ts @@ -19,6 +19,7 @@ test("public API exposes documented members", () => { "useStderr", "useTerminalSize", "useCursor", + "useIsScreenReaderEnabled", ]) { expect(api).toHaveProperty(k); } diff --git a/packages/runtime/src/composables/useIsScreenReaderEnabled.ts b/packages/runtime/src/composables/useIsScreenReaderEnabled.ts new file mode 100644 index 0000000..ee5ad4c --- /dev/null +++ b/packages/runtime/src/composables/useIsScreenReaderEnabled.ts @@ -0,0 +1,9 @@ +import { inject } from "vue"; +import { AppContextKey } from "../context.ts"; + +export function useIsScreenReaderEnabled(): boolean { + const ctx = inject(AppContextKey); + if (!ctx) + throw new Error("useIsScreenReaderEnabled() must be called inside a vue-tui render tree"); + return ctx.isScreenReaderEnabled; +} diff --git a/packages/runtime/src/context.ts b/packages/runtime/src/context.ts index 69b6a5a..726dafa 100644 --- a/packages/runtime/src/context.ts +++ b/packages/runtime/src/context.ts @@ -13,6 +13,7 @@ export interface AppContext { stdin: NodeJS.ReadStream; debug: boolean; interactive: boolean; + isScreenReaderEnabled: boolean; isRawModeSupported: boolean; setRawMode: (mode: boolean) => void; writeToStdout: (data: string) => void; diff --git a/packages/runtime/src/index.ts b/packages/runtime/src/index.ts index 27722a5..2d0bd75 100644 --- a/packages/runtime/src/index.ts +++ b/packages/runtime/src/index.ts @@ -18,6 +18,7 @@ export { useStdout } from "./composables/useStdout.ts"; export { useStderr } from "./composables/useStderr.ts"; export { useTerminalSize } from "./composables/useTerminalSize.ts"; export { useCursor } from "./composables/useCursor.ts"; +export { useIsScreenReaderEnabled } from "./composables/useIsScreenReaderEnabled.ts"; export { useAnimation, type AnimationOptions, diff --git a/packages/runtime/src/render-to-string.ts b/packages/runtime/src/render-to-string.ts index 119ffd4..ea32603 100644 --- a/packages/runtime/src/render-to-string.ts +++ b/packages/runtime/src/render-to-string.ts @@ -157,6 +157,7 @@ function createNoOpAppContext(): AppContext { stdin: process.stdin, debug: false, interactive: false, + isScreenReaderEnabled: false, isRawModeSupported: false, setRawMode: () => {}, writeToStdout: () => {}, diff --git a/packages/runtime/src/render.ts b/packages/runtime/src/render.ts index 7f9287b..6598fd6 100644 --- a/packages/runtime/src/render.ts +++ b/packages/runtime/src/render.ts @@ -75,6 +75,13 @@ export interface MountOptions { * Ignored in debug mode (commits are immediate). */ maxFps?: number; + /** + * Enable screen reader mode. When enabled, the commit scheduler bypasses + * throttling (immediate commits) so every frame is flushed without delay. + * + * @default true when `process.env["INK_SCREEN_READER"] === "true"`, otherwise false + */ + isScreenReaderEnabled?: boolean; } export interface TuiApp extends Omit, "mount"> { @@ -215,6 +222,8 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp const rawMode = options.rawMode ?? true; const onRender = options.onRender; const maxFps = options.maxFps; + const isScreenReaderEnabled = + options.isScreenReaderEnabled ?? process.env["INK_SCREEN_READER"] === "true"; mountedDebug = debug; // Interactive mode detection — matches Ink's logic: @@ -298,6 +307,7 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp stdin, debug, interactive, + isScreenReaderEnabled, isRawModeSupported: !!(stdin as { isTTY?: boolean }).isTTY, setRawMode(mode: boolean) { if ( @@ -391,8 +401,10 @@ export function createApp(root: Component, rootProps?: RootProps | null): TuiApp if (onRender) onRender({ renderTime: performance.now() - start }); } - const schedulerOptions: { immediate: boolean; throttleMs?: number } = { immediate: debug }; - if (maxFps != null && !debug) { + const schedulerOptions: { immediate: boolean; throttleMs?: number } = { + immediate: debug || isScreenReaderEnabled, + }; + if (maxFps != null && !debug && !isScreenReaderEnabled) { schedulerOptions.throttleMs = Math.round(1000 / maxFps); } const scheduler = createCommitScheduler(commit, schedulerOptions);