fix(app): restore character-paced streaming replies (#5013)

* fix(app): restore character-paced streaming reveal

Restore the v0.8 character reveal model and remove the word-level fade implementation so streamed assistant and reasoning text remains opaque, grapheme-safe, and immediately completes when a turn ends.

* fix(android): preserve inverted timeline selection

React Native's prebuilt Android library does not consume source patches under node_modules. Register the compiled scroll manager before the core package so inverted child-reveal calculations leave focus and text selection in place while normal and explicit scrolling remain unchanged.
This commit is contained in:
Mohamed Boudra
2026-09-17 18:32:12 +02:00
committed by GitHub
parent dc682adb2a
commit 5d70ab2ab8
60 changed files with 890 additions and 2945 deletions
+4 -33
View File
@@ -22,7 +22,7 @@ Every provider delivers incremental text, so there is no provider that needs spe
Arrival is lumpy and there is no fixing that at the source. A 60ms coalescing window carries however many characters the model produced in those 60ms, which swings by an order of magnitude within a single turn. Painting each delta as it lands makes the size of those lumps visible, and that is what reads as jagged.
So arrival sets a _target_ and the reveal rate is derived from the backlog instead. A burst shortens the interval within the word cadence. A late frame releases one word, without spending missed frames as credit to show a group at once. Shrinking the coalescing window does not fix this — it makes the lumps smaller and more frequent, at the cost of message rate on a daemon loop that already contends with terminal frames and per-message relay encryption.
So arrival sets a _target_ and the reveal rate is derived from the backlog instead. A burst makes the text catch up faster; it does not make the text jump. Shrinking the coalescing window does not fix this — it makes the lumps smaller and more frequent, at the cost of message rate on a daemon loop that already contends with terminal frames and per-message relay encryption.
## Invariants
@@ -33,7 +33,7 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
blocks and parses only the growing last block during append. Splitting in the reducer discards
paragraph separators and exposes fragments to plugin callbacks, which need the whole source text.
- **First sight of a text is revealed whole.** Only growth is paced. This is what makes history hydration, timeline replay, a virtualized row remounting on scroll, and an already-finished message all render complete on first paint without a special case for each.
- **Completion releases an unfinished word.** Every platform drains queued words through the same pacing when the provider finishes; the last word also finishes fading.
- **Leaving `phase: "streaming"` snaps the reveal.** A completed turn must never be left holding characters. `layoutStream` sets the phase, so anything outside the live head with an active turn is already complete.
- **The reducer queue commits on a frame, with a timer as the ceiling.** A frame callback never fires in a hidden tab, so a timer races it and wins when nothing is painting — the store has to keep advancing either way.
- **A history row re-renders only when its item or layout item identity changes.** The inverted
FlatList hands every mounted cell a new `index` and `ref` whenever a row is prepended, so without a
@@ -47,40 +47,11 @@ So arrival sets a _target_ and the reveal rate is derived from the backlog inste
## Measuring
Word reveal lives in `packages/app/src/word-stream`. Native attribution and pinned
upstream revisions are in `packages/app/modules/paseo-word-stream/UPSTREAM.md`.
Judge the visible front across word boundaries at the recording's native frame
rate. No character should become more opaque than text before it in reading
order, and the front should not visibly change speed at word boundaries.
Sampling at 100 ms hid the previous defect: later letters appeared before earlier
letters, leaving holes that read as flashing words during playback.
Exercise provisional Markdown and recycled views when checking fade-once behavior.
A table forming or inline formatting closing can change the rendered structure
without making previously visible text new again.
On native, text and its fade metadata must become visible together. An earlier
implementation sent metadata asynchronously and flashed new text fully opaque
for one frame before dimming it. Preserve native layout, selection, and inline
styles, and keep animation ticks off the JS thread. Do not remove the animation
host's layout box with `display: contents`: RN forcibly flattens it and removes
the native animation owner. iOS table cells receive pacing without opacity
animation; include that limit in platform QA reports.
Compare release APKs on the same Android device with the mock `bursty-stream`
provider, identical prompts, fresh conversations, and equal sampling windows.
Collect `agent-device perf frames` and `perf memory sample`; keep screen recording
out of the timed runs. Software-rendered emulator results are comparative
evidence, not physical-device frame-rate guarantees. Native instrumentation
tests in the module check pixels, append/replacement behavior, and cleanup.
- **Smoothness (user-perceived):** `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`, gated behind `PASEO_AGENT_STREAM_PERF_E2E=1`. Drives the mock provider's `bursty-stream` model and reports coefficient of variation of characters painted per frame (smoothness) plus p95 gap between visible updates (stalls). Both numbers are needed: a stalled stream is perfectly smooth.
- **Reproducing bursty arrival:** the `bursty-stream` model in `mock-load-test-agent.ts` emits uneven runs of tokens separated by idle gaps. Burst sizes come from a seeded generator, so a run repeats exactly.
- **Rate policy in isolation:** `packages/app/src/word-stream/internal/model.test.ts` checks the shared word scheduler without a renderer.
- **Fade behavior:** `word-stream-fade.spec.ts` checks web direction, layout stability, selection, and tail cleanup. Native tests in `modules/paseo-word-stream` drive the host view React mounts with the same range props the bridge sends, advance frame time through the host's injected frame clock, and check rendered pixels and animation completion on Android and iOS.
- **Rate policy in isolation:** `computeRevealStep` in `packages/app/src/agent-stream/text-reveal.ts` is pure; `text-reveal.test.ts` covers convergence and burst flattening without a renderer.
Historical character-reveal baseline (2026-08, Expo web against a local dev daemon, real Claude Haiku agent, ~8.5s samples during active streaming). The paint-on-arrival column disabled the former reveal horizon:
Healthy numbers (2026-08, Expo web against a local dev daemon, real Claude Haiku agent, ~8.5s samples during active streaming). Setting `TEXT_REVEAL_HORIZON_MS` to 0 makes the reveal paint on arrival, which is how the baseline column was taken:
| | paint on arrival | paced |
| -------------------------------- | ---------------- | ----- |
+11
View File
@@ -120,6 +120,17 @@ REACT_NATIVE_PACKAGER_HOSTNAME=localhost \
This is the Android counterpart of the iOS local-simulator flow in [development.md](development.md): on iOS the simulator shares the Mac's loopback so `localhost:<port>` works directly; on Android you need `10.0.2.2` or `adb reverse`.
## Inverted timeline selection
Android focus and selection visibility requests must not reposition inverted timelines. The
`modules/paseo-scroll` package keeps React Native's scroll manager interface and returns zero for
child-reveal scroll calculations when the vertical scale is inverted. Dragging and explicit scroll
commands still work; non-inverted scroll views keep Android's default behavior.
Register this package before React Native's core package through its Expo config plugin. Normal
Android builds use the prebuilt `react-android` library, so patching Java under `node_modules` does
not change the shipped scroll view. Keep this behavior in the app's compiled native module.
## F-Droid / source-only Android builds
F-Droid builds should set `PASEO_FDROID_BUILD=1` when running Expo prebuild:
+1 -1
View File
@@ -1 +1 @@
sha256-WHqWMIqganiTllugHTlN339vjgHyaCn9i29rQqKcBKo=
sha256-6+B64UqX8XtVdWFNtg884SIla6FXBOTtNSO0OMFoQfY=
-7
View File
@@ -34614,12 +34614,6 @@
"node": ">=4"
}
},
"node_modules/unicode-segmenter": {
"version": "0.17.3",
"resolved": "https://registry.npmjs.org/unicode-segmenter/-/unicode-segmenter-0.17.3.tgz",
"integrity": "sha512-hKZwqBjJDmqNrq1+LjDxck1qLzJFcLLJM2Xq92ORRHOuau6GSHeELmn+uyk6zNH1CK9mogrUJGP9WJCJUQXMAw==",
"license": "MIT"
},
"node_modules/unified": {
"version": "11.0.5",
"resolved": "https://registry.npmjs.org/unified/-/unified-11.0.5.tgz",
@@ -36295,7 +36289,6 @@
"tiny-invariant": "^1.3.3",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"unicode-segmenter": "0.17.3",
"use-sync-external-store": "^1.6.0",
"yaml": "^2.8.4",
"zod": "^4.4.3",
+2
View File
@@ -5,6 +5,7 @@ const withAndroidAsyncStorageSize = require("./plugins/with-android-async-storag
const withAndroidProfileable = require("./plugins/with-android-profileable");
const withFdroidAutolinking = require("./plugins/with-fdroid-autolinking");
const withPasteInput = require("./plugins/with-paste-input");
const withAndroidScroll = require("./modules/paseo-scroll/app.plugin");
const { getNativeReleaseVersion } = require("./native-release-version");
const appVariant = process.env.APP_VARIANT ?? "production";
const isFdroidBuild = process.env.PASEO_FDROID_BUILD === "1";
@@ -141,6 +142,7 @@ export default {
plugins: [
"expo-router",
withPasteInput,
withAndroidScroll,
[withAndroidAsyncStorageSize, 64],
...buildProfile.cameraPlugins,
[
@@ -16,17 +16,6 @@ import {
waitForScrollableChat,
} from "../support/helpers/agent-bottom-anchor";
import { delayCreatedAgentInitialTailResponse } from "../support/helpers/agent-timeline-gate";
import {
collapseSettledWord,
installMarkdownRootObserver,
mountObservedSyntheticBlock,
readMarkdownRootEvidence,
replaceParagraph,
replaceParagraphKeepingFadingWord,
restartFadingWord,
resumeFadingWord,
settleMarkdownRootObserver,
} from "../support/helpers/markdown-root-stability";
import { selectModel } from "../support/helpers/app";
import { clickNewChat } from "../support/helpers/launcher";
import { expectComposerVisible, startRunningMockAgent } from "../support/helpers/composer";
@@ -74,55 +63,6 @@ test.describe("Agent stream UI", () => {
}
});
test("the Markdown root observer tells designed fade churn from replacements", async ({
page,
}) => {
await test.step("a word re-created with its start time resumes its fade", async () => {
await mountObservedSyntheticBlock(page);
await resumeFadingWord(page);
expect(await readMarkdownRootEvidence(page)).toEqual({
replacedElements: 0,
restartedFades: 0,
});
});
await test.step("a word re-created with a new start time is a restarted fade", async () => {
await mountObservedSyntheticBlock(page);
await restartFadingWord(page);
expect(await readMarkdownRootEvidence(page)).toEqual({
replacedElements: 0,
restartedFades: 1,
});
});
await test.step("a settled word collapsing into text is not a replacement", async () => {
await mountObservedSyntheticBlock(page);
await collapseSettledWord(page);
expect(await readMarkdownRootEvidence(page)).toEqual({
replacedElements: 0,
restartedFades: 0,
});
});
await test.step("a re-created paragraph is a replaced element", async () => {
await mountObservedSyntheticBlock(page);
await replaceParagraph(page);
expect(await readMarkdownRootEvidence(page)).toEqual({
replacedElements: 1,
restartedFades: 0,
});
});
await test.step("a re-created paragraph is still a replacement when it keeps its fading word", async () => {
await mountObservedSyntheticBlock(page);
await replaceParagraphKeepingFadingWord(page);
expect(await readMarkdownRootEvidence(page)).toEqual({
replacedElements: 1,
restartedFades: 0,
});
});
});
test("keeps the active Markdown root mounted across streamed text updates", async ({
page,
}, testInfo) => {
@@ -147,20 +87,45 @@ test.describe("Agent stream UI", () => {
throw new Error("Expected the active assistant block to contain a Markdown root");
}
await page.evaluate(installMarkdownRootObserver, activeBlockHandle);
await page.evaluate((block) => {
const evidence = { addedNodes: 0, characterDataMutations: 0, removedNodes: 0 };
const observer = new MutationObserver((records) => {
for (const record of records) {
evidence.addedNodes += record.addedNodes.length;
evidence.removedNodes += record.removedNodes.length;
if (record.type === "characterData") evidence.characterDataMutations += 1;
}
});
observer.observe(block, { characterData: true, childList: true, subtree: true });
Object.assign(window, {
__markdownRootEvidence: evidence,
__markdownRootObserver: observer,
});
}, activeBlockHandle);
await expect
.poll(async () => ((await activeBlock.textContent()) ?? "").length)
.toBeGreaterThan(initialText.length + 80);
const settled = await page.evaluate(settleMarkdownRootObserver);
const rootState = await page.evaluate((root) => {
const evidence = await page.evaluate((root) => {
const state = window as typeof window & {
__markdownRootEvidence?: {
addedNodes: number;
characterDataMutations: number;
removedNodes: number;
};
__markdownRootObserver?: MutationObserver;
};
state.__markdownRootObserver?.disconnect();
const messages = document.querySelectorAll('[data-testid="assistant-message"]');
const message = messages.item(messages.length - 1);
const block = message?.lastElementChild;
return { connected: root.isConnected, sameRoot: block?.firstElementChild === root };
return {
...state.__markdownRootEvidence,
connected: root.isConnected,
sameRoot: block?.firstElementChild === root,
};
}, markdownRoot);
const evidence = { ...settled, ...rootState };
await testInfo.attach("markdown-root-stability", {
body: JSON.stringify(evidence, null, 2),
@@ -169,13 +134,9 @@ test.describe("Agent stream UI", () => {
expect(evidence.connected).toBe(true);
expect(evidence.sameRoot).toBe(true);
expect(
evidence.replacedElements,
evidence.removedNodes,
`Streaming Markdown replaced mounted descendants: ${JSON.stringify(evidence)}`,
).toBe(0);
expect(
evidence.restartedFades,
`Streaming Markdown re-created a fading word with a new start time: ${JSON.stringify(evidence)}`,
).toBe(0);
} finally {
await agent.cleanup();
}
@@ -1,14 +0,0 @@
import { cancelAgent } from "../support/helpers/composer";
import {
test,
expectContinuousWordFade,
expectSettledSelectableText,
} from "../support/helpers/word-stream-fade";
test("released words fade left to right and settle into selectable text", async ({
streamingReply,
}, testInfo) => {
await expectContinuousWordFade(streamingReply, testInfo);
await cancelAgent(streamingReply);
await expectSettledSelectableText(streamingReply);
});
@@ -1,196 +0,0 @@
/**
* Observes a streaming Markdown block and classifies DOM churn.
*
* The word fade wraps each arriving word in a `data-word-fade` span carrying
* the word's start time, and collapses it back into plain text once the fade
* has settled, so text nodes around the tail and settled spans come and go by
* design. When Markdown re-parses and a word's rendered offset shifts, its span
* is re-created with the same start time and the fade resumes. Two things are
* defects:
*
* - `replacedElements`: an element other than a fade span left the block, so
* Markdown re-created a mounted descendant. Text nodes alone cannot form a
* Markdown descendant.
* - `restartedFades`: a fade span was removed before it settled and no span with
* that start time was added afterwards, so the word was faded again.
*
* Both functions run inside the page through `page.evaluate`, so they must be
* self-contained: no references to module scope.
*/
import type { Page } from "@playwright/test";
export interface MarkdownRootEvidence {
addedNodes: number;
characterDataMutations: number;
removedNodes: number;
replacedElements: number;
restartedFades: number;
}
interface MarkdownRootObserverWindow {
__markdownRootSettle?: () => MarkdownRootEvidence;
}
export function installMarkdownRootObserver(block: Element): void {
const fadeDurationMs = 150;
const frameMs = 16;
const evidence: MarkdownRootEvidence = {
addedNodes: 0,
characterDataMutations: 0,
removedNodes: 0,
replacedElements: 0,
restartedFades: 0,
};
// Unsettled removals waiting for a same-start-time addition. An addition
// only resumes a removal that already happened; React removes the old span
// before placing the new one within a commit.
const pendingResumes = new Map<string, number>();
const fadeSpans = (node: Node): HTMLElement[] => {
if (!(node instanceof HTMLElement)) return [];
return node.hasAttribute("data-word-fade")
? [node]
: Array.from(node.querySelectorAll<HTMLElement>("[data-word-fade]"));
};
// The renderer collapses a word when its last letter's animation ends. That
// letter starts at startedAt + spanMs * (letters - 1) / letters.
const isSettled = (span: HTMLElement) => {
const letters = Math.max(1, span.childElementCount);
const settledAt =
Number(span.dataset.fadeStarted) +
(Number(span.dataset.fadeSpan) * (letters - 1)) / letters +
fadeDurationMs;
return Number.isFinite(settledAt) && Date.now() + frameMs >= settledAt;
};
const handle = (records: MutationRecord[]) => {
for (const record of records) {
evidence.addedNodes += record.addedNodes.length;
evidence.removedNodes += record.removedNodes.length;
if (record.type === "characterData") evidence.characterDataMutations += 1;
for (const node of record.removedNodes) {
// Any removed element other than a fade span is a replaced descendant,
// whatever it contains. Fade spans it carried away were removed too.
if (node instanceof Element && !node.hasAttribute("data-word-fade")) {
evidence.replacedElements += 1;
}
for (const span of fadeSpans(node)) {
const started = span.dataset.fadeStarted;
if (!started || isSettled(span)) continue;
pendingResumes.set(started, (pendingResumes.get(started) ?? 0) + 1);
}
}
for (const node of record.addedNodes) {
for (const span of fadeSpans(node)) {
const started = span.dataset.fadeStarted;
const pending = started ? pendingResumes.get(started) : undefined;
if (!started || pending === undefined) continue;
if (pending > 1) pendingResumes.set(started, pending - 1);
else pendingResumes.delete(started);
}
}
}
};
const observer = new MutationObserver(handle);
observer.observe(block, { characterData: true, childList: true, subtree: true });
const settle = () => {
handle(observer.takeRecords());
observer.disconnect();
let restarted = 0;
for (const count of pendingResumes.values()) restarted += count;
evidence.restartedFades = restarted;
return evidence;
};
(window as unknown as MarkdownRootObserverWindow).__markdownRootSettle = settle;
}
export function settleMarkdownRootObserver(): MarkdownRootEvidence {
const settle = (window as unknown as MarkdownRootObserverWindow).__markdownRootSettle;
if (!settle) throw new Error("installMarkdownRootObserver has not run on this page");
return settle();
}
// Synthetic streaming block: the same DOM shapes the fade renderer produces,
// driven by hand so each classification can be exercised in isolation.
const SYNTHETIC_BLOCK_ID = "markdown-root-stability-block";
export async function mountObservedSyntheticBlock(page: Page): Promise<void> {
await page.setContent(`<div id="${SYNTHETIC_BLOCK_ID}"><p>settled </p></div>`);
const block = await page.locator(`#${SYNTHETIC_BLOCK_ID}`).elementHandle();
if (!block) throw new Error("Expected the synthetic Markdown block to mount");
await page.evaluate(installMarkdownRootObserver, block);
}
type SyntheticMutation =
| "resume-fading-word"
| "restart-fading-word"
| "collapse-settled-word"
| "replace-paragraph"
| "replace-paragraph-keeping-fading-word";
async function mutateSyntheticBlock(page: Page, mutation: SyntheticMutation): Promise<void> {
await page.evaluate(
([blockId, kind]) => {
const p = document.querySelector(`#${blockId} > p`) as HTMLElement;
const fadingWord = (startedAt: number) => {
const span = document.createElement("span");
span.setAttribute("data-word-fade", "0");
span.dataset.fadeStarted = String(startedAt);
span.dataset.fadeSpan = "60";
span.textContent = "word";
return span;
};
const replaceParagraphWith = (child?: HTMLElement) => {
const next = document.createElement("p");
next.textContent = p.textContent;
if (child) next.append(child);
p.replaceWith(next);
};
// Wall-clock start times are unsettled at removal unless backdated.
const now = Date.now();
switch (kind) {
case "resume-fading-word":
p.append(fadingWord(now));
p.lastElementChild!.remove();
p.append(fadingWord(now));
return;
case "restart-fading-word":
p.append(fadingWord(now));
p.lastElementChild!.remove();
p.append(fadingWord(now + 1));
return;
case "collapse-settled-word":
p.append(fadingWord(now - 10_000));
p.lastElementChild!.remove();
p.append("word");
return;
case "replace-paragraph":
replaceParagraphWith();
return;
case "replace-paragraph-keeping-fading-word":
p.append(fadingWord(now));
replaceParagraphWith(fadingWord(now));
return;
}
},
[SYNTHETIC_BLOCK_ID, mutation] as const,
);
}
/** Remove a fading word and re-create it with the same start time. */
export const resumeFadingWord = (page: Page) => mutateSyntheticBlock(page, "resume-fading-word");
/** Remove a fading word and re-create it with a new start time. */
export const restartFadingWord = (page: Page) => mutateSyntheticBlock(page, "restart-fading-word");
/** Collapse a word whose fade already finished back into plain text. */
export const collapseSettledWord = (page: Page) =>
mutateSyntheticBlock(page, "collapse-settled-word");
/** Re-create the paragraph element with the same text. */
export const replaceParagraph = (page: Page) => mutateSyntheticBlock(page, "replace-paragraph");
/** Re-create the paragraph while carrying its fading word over with the same start time. */
export const replaceParagraphKeepingFadingWord = (page: Page) =>
mutateSyntheticBlock(page, "replace-paragraph-keeping-fading-word");
export async function readMarkdownRootEvidence(page: Page) {
const evidence = await page.evaluate(settleMarkdownRootObserver);
return { replacedElements: evidence.replacedElements, restartedFades: evidence.restartedFades };
}
@@ -59,11 +59,8 @@ export async function expectUnfinishedLink(
): Promise<void> {
const message = page.getByTestId("assistant-message").last();
await agent.stream.showThrough("**Bold text stays bold** and [Paseo docs");
// The next word includes the closing label and URL, so word pacing releases
// it only when the link is complete. Observe the first complete label word.
await expect(message).toContainText("Paseo");
await expect(message).not.toContainText("docs");
await expect(message.getByRole("link")).toHaveCount(0);
await expect(message).toContainText("Paseo docs");
await expect(message.getByRole("link", { name: "Paseo docs" })).toHaveCount(0);
await expect(message).not.toContainText("[");
await expect(message).not.toContainText("https:");
await captureMarkdown(page, testInfo, "unfinished-link");
@@ -1,112 +0,0 @@
import { expect, type Page, type TestInfo } from "@playwright/test";
import { test as base } from "../fixtures";
import { startRunningMockAgent } from "./composer";
import { awaitAssistantMessage } from "./agent-stream";
export const test = base.extend<{ streamingReply: Page }>({
streamingReply: async ({ page }, provide) => {
const agent = await startRunningMockAgent(page, {
prefix: "word-fade-",
model: "bursty-stream",
prompt: "Stream bursty output for word fade verification.",
});
try {
await awaitAssistantMessage(page);
await provide(page);
} finally {
await agent.cleanup();
}
},
});
export async function expectContinuousWordFade(page: Page, testInfo: TestInfo): Promise<void> {
const report = await sampleWordFades(page);
await testInfo.attach("word-fade-observations", {
body: JSON.stringify(report),
contentType: "application/json",
});
expect(report.directionalFades).toBeGreaterThan(10);
expect(report.reverseFades).toBe(0);
expect(report.boundaryReversals).toBe(0);
expect(report.maxWidthDelta).toBeLessThan(0.5);
expect(report.maxActiveLetters).toBeLessThan(500);
}
export async function sampleWordFades(page: Page) {
return page.evaluate(async () => {
const started = performance.now();
let directionalFades = 0;
let reverseFades = 0;
let boundaryReversals = 0;
let maxBoundaryReversal = 0;
let maxWidthDelta = 0;
let maxActiveLetters = 0;
while (performance.now() - started < 4_000) {
const words = Array.from(document.querySelectorAll<HTMLElement>("[data-word-fade]"));
maxActiveLetters = Math.max(
maxActiveLetters,
words.reduce((sum, word) => sum + word.children.length, 0),
);
// One front across the whole line: a word's first letter is never more
// opaque than the last letter of the fading word before it on that line.
let previous: { top: number; opacity: number } | null = null;
for (const word of words) {
const letters = Array.from(word.children);
const rect = word.getBoundingClientRect();
const first = Number(getComputedStyle(letters[0]!).opacity);
const last = Number(getComputedStyle(letters.at(-1)!).opacity);
if (previous && Math.abs(previous.top - rect.top) < 1 && first > previous.opacity + 0.02) {
boundaryReversals++;
maxBoundaryReversal = Math.max(maxBoundaryReversal, first - previous.opacity);
}
previous = { top: rect.top, opacity: last };
if (letters.length < 3) continue;
if (first > last) directionalFades++;
if (last > first) reverseFades++;
if (word.getClientRects().length !== 1) continue;
// Compare shaped text with the same word without transient spans.
const plain = document.createElement("span");
plain.textContent = word.textContent;
Object.assign(plain.style, {
position: "absolute",
whiteSpace: "pre",
visibility: "hidden",
});
word.parentElement!.appendChild(plain);
maxWidthDelta = Math.max(
maxWidthDelta,
Math.abs(plain.getBoundingClientRect().width - word.getBoundingClientRect().width),
);
plain.remove();
}
await new Promise(requestAnimationFrame);
}
return {
directionalFades,
reverseFades,
boundaryReversals,
maxBoundaryReversal,
maxWidthDelta,
maxActiveLetters,
};
});
}
export async function expectSettledSelectableText(page: Page): Promise<void> {
await expect(page.locator("[data-word-fade]")).toHaveCount(0);
const result = await page
.getByTestId("assistant-message")
.first()
.evaluate((message) => {
const range = document.createRange();
range.selectNodeContents(message);
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
const selectedText = { expected: message.textContent, selected: selection.toString() };
selection.removeAllRanges();
return selectedText;
});
expect(result.selected.length).toBeGreaterThan(0);
expect(result.selected.replace(/\s/g, "")).toBe(result.expected!.replace(/\s/g, ""));
}
@@ -0,0 +1,2 @@
/android/.cxx/
/android/build/
@@ -1,23 +1,23 @@
apply plugin: 'com.android.library'
group = 'sh.paseo.wordstream'
group = 'sh.paseo.scroll'
version = '0.1.0'
def expoModulesCorePlugin = new File(project(":expo-modules-core").projectDir.absolutePath, "ExpoModulesCorePlugin.gradle")
apply from: expoModulesCorePlugin
applyKotlinExpoModulesCorePlugin()
useCoreDependencies()
useExpoPublishing()
useDefaultAndroidSdkVersions()
android {
namespace "sh.paseo.wordstream"
namespace "sh.paseo.scroll"
defaultConfig {
testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
}
}
dependencies {
implementation "com.facebook.react:react-android"
androidTestImplementation 'androidx.test:runner:1.6.2'
androidTestImplementation 'androidx.test.ext:junit:1.2.1'
}
@@ -0,0 +1,77 @@
package sh.paseo.scroll
import android.graphics.Rect
import android.text.Selection
import android.text.Spannable
import android.view.View
import android.widget.FrameLayout
import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.facebook.react.bridge.BridgeReactContext
import com.facebook.react.soloader.OpenSourceMergedSoMapping
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.views.scroll.ReactScrollView
import com.facebook.react.views.scroll.ReactScrollViewManager
import com.facebook.soloader.SoLoader
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
@RunWith(AndroidJUnit4::class)
class ScrollSelectionTest {
@Test fun focusingSelectableTextKeepsInvertedViewportStill() = withScroll(inverted = true) { scroll, text ->
val before = scroll.scrollY
assertTrue(text.requestFocus())
assertSame(text, scroll.findFocus())
assertEquals("Text focus must not move the inverted viewport", before, scroll.scrollY)
assertTrue(text.isTextSelectable)
}
@Test fun selectingTextKeepsInvertedViewportStill() = withScroll(inverted = true) { scroll, text ->
val before = scroll.scrollY
Selection.setSelection(text.text as Spannable, 0, 8)
text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
assertEquals("Selection visibility requests must not move the inverted viewport", before, scroll.scrollY)
assertEquals("Selectab", text.text.subSequence(text.selectionStart, text.selectionEnd).toString())
}
@Test fun explicitScrollCommandsStillMoveInvertedViewport() = withScroll(inverted = true) { scroll, _ ->
scroll.scrollTo(0, 600)
assertEquals(600, scroll.scrollY)
}
@Test fun ordinaryScrollViewsStillRevealSelectedText() = withScroll(inverted = false) { scroll, text ->
val before = scroll.scrollY
text.requestRectangleOnScreen(Rect(0, 0, 80, 40), true)
assertNotEquals(before, scroll.scrollY)
}
private fun withScroll(inverted: Boolean, check: (ReactScrollView, TextView) -> Unit) {
val instrumentation = InstrumentationRegistry.getInstrumentation()
instrumentation.runOnMainSync {
val appContext = instrumentation.targetContext
SoLoader.init(appContext, OpenSourceMergedSoMapping)
val reactContext = BridgeReactContext(appContext)
val context = ThemedReactContext(reactContext, appContext)
val manager = PaseoScrollPackage().createViewManager(reactContext, ReactScrollViewManager.REACT_CLASS) as ReactScrollViewManager
val scroll = manager.createViewInstance(context)
scroll.scaleY = if (inverted) -1f else 1f
val content = FrameLayout(context)
val text = TextView(context).apply {
setText("Selectable timeline text", TextView.BufferType.SPANNABLE)
setTextIsSelectable(true)
}
content.addView(text, FrameLayout.LayoutParams(300, 100).apply { topMargin = 1400 })
scroll.addView(content)
content.measure(exact(300), exact(2000))
content.layout(0, 0, 300, 2000)
scroll.measure(exact(300), exact(300))
scroll.layout(0, 0, 300, 300)
scroll.scrollTo(0, 400)
check(scroll, text)
}
}
private fun exact(size: Int) = View.MeasureSpec.makeMeasureSpec(size, View.MeasureSpec.EXACTLY)
}
@@ -0,0 +1,35 @@
package sh.paseo.scroll
import android.graphics.Rect
import com.facebook.react.ReactPackage
import com.facebook.react.ViewManagerOnDemandReactPackage
import com.facebook.react.bridge.NativeModule
import com.facebook.react.bridge.ReactApplicationContext
import com.facebook.react.uimanager.ThemedReactContext
import com.facebook.react.uimanager.ViewManager
import com.facebook.react.views.scroll.ReactScrollView
import com.facebook.react.views.scroll.ReactScrollViewManager
/** Keeps RN's scroll props, commands, events, and Fabric component unchanged. */
class PaseoScrollPackage : ReactPackage, ViewManagerOnDemandReactPackage {
override fun createNativeModules(context: ReactApplicationContext): List<NativeModule> = emptyList()
override fun createViewManagers(context: ReactApplicationContext): List<ViewManager<*, *>> =
listOf(PaseoScrollViewManager())
override fun getViewManagerNames(context: ReactApplicationContext): Collection<String> =
listOf(ReactScrollViewManager.REACT_CLASS)
override fun createViewManager(context: ReactApplicationContext, name: String): ViewManager<*, *>? =
if (name == ReactScrollViewManager.REACT_CLASS) PaseoScrollViewManager() else null
}
private class PaseoScrollViewManager : ReactScrollViewManager() {
override fun createViewInstance(context: ThemedReactContext): ReactScrollView =
PaseoScrollView(context)
}
private class PaseoScrollView(context: ThemedReactContext) : ReactScrollView(context) {
// Both RN focus handling and Android selection visibility requests use this calculation.
// Its coordinates ignore inversion. Keep focus/selection bookkeeping, but leave timeline
// movement to user gestures and explicit scroll commands.
override fun computeScrollDeltaToGetChildRectOnScreen(rect: Rect): Int =
if (scaleY < 0) 0 else super.computeScrollDeltaToGetChildRectOnScreen(rect)
}
@@ -0,0 +1,21 @@
const { withMainApplication } = require("expo/config-plugins");
const PACKAGE_LIST = "PackageList(this).packages.apply {";
const REGISTRATION = "add(0, sh.paseo.scroll.PaseoScrollPackage())";
function configureScrollPackage(contents) {
if (contents.includes(REGISTRATION)) return contents;
if (!contents.includes(PACKAGE_LIST)) {
throw new Error(
"Could not register Android scroll behavior before React Native's core package",
);
}
return contents.replace(PACKAGE_LIST, `${PACKAGE_LIST}\n ${REGISTRATION}`);
}
module.exports = (config) =>
withMainApplication(config, (mod) => {
mod.modResults.contents = configureScrollPackage(mod.modResults.contents);
return mod;
});
module.exports.configureScrollPackage = configureScrollPackage;
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
const { configureScrollPackage } = require("./app.plugin");
describe("Android scroll registration", () => {
it("gives the app scroll manager precedence over the prebuilt React Native manager", () => {
const source = "PackageList(this).packages.apply {\n}";
const result = configureScrollPackage(source);
expect(result).toBe(
"PackageList(this).packages.apply {\n add(0, sh.paseo.scroll.PaseoScrollPackage())\n}",
);
expect(configureScrollPackage(result)).toBe(result);
});
it("fails prebuild when the template no longer supports registration", () => {
expect(() => configureScrollPackage("changed template")).toThrow(
"Could not register Android scroll behavior",
);
});
});
@@ -0,0 +1 @@
{ "platforms": ["android"], "android": { "modules": [] } }
@@ -1,5 +1,5 @@
{
"name": "paseo-word-stream",
"name": "paseo-scroll",
"version": "0.1.0",
"private": true
}
@@ -1,20 +0,0 @@
MIT License
Copyright (c) 2025 Software Mansion
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
@@ -1,46 +0,0 @@
# Attribution
`TailFadeInAnimator.kt` and `FadeInSpan.kt` adapt Software Mansion's native
text-range animation from `enriched-markdown`, commit
`019b88facf23de7b84a1b044e368e49a62b6d66c`. Its MIT license is in `LICENSE`.
Paseo supplies word-paced text and message-owned fade ranges with their original
start times and the interval each word owns before the next. The adapter applies
those ranges to the child text surface in the same Fabric transaction and retains the upstream
`CharacterStyle` alpha span. Upstream drives one `ValueAnimator` per appended
tail; Paseo gives every grapheme an absolute start time in native Unicode visual
order and ticks one `Choreographer` clock per surface, so consecutive words form
one front. It does not own Markdown parsing or network pacing.
Do not put a gradient shader on `TextPaint`: Android also uses that paint for
inline-code backgrounds, making them flash in the foreground color.
Do not require `TextView.layout` to start a fade. React Native can replace text
while keeping its bounds, leaving layout null until `TextView.onDraw`. Requiring
layout in pre-draw silently skips those appended words.
FlatList clipping can detach a host while its React component remains mounted.
Stop its clock on detach and reconnect pre-draw on attach. Resume using the
message's absolute start times; never infer an appended suffix from native text.
The host's ranges prop and child text share Fabric's mount transaction.
Observe the read-only text before painting. Registering a `TextWatcher` makes
Android convert later text replacements into `Editable`, changing its layout
and drawing-cache path. The binding must preserve RN's original text buffer.
`ios/internal/TailFadeInAnimator.swift` adapts Software Mansion's
`ios/utils/ENRMTailFadeInAnimator.m` at commit
`4c1270f48f19f5da88bc0b751e2a070bd0f989b9`: original-color snapshots,
`NSTextStorage` foreground attributes, and a `CADisplayLink` that stops when the
tail settles. Paseo retains its word pacing; each grapheme has an absolute start
time and the same 150 ms linear envelope, and CoreText supplies visual grapheme order.
The existing RN UITextView host assigns attributed text during `drawRect`. Flush
its pending display before applying ranges, otherwise the observer can
animate hydrated history or miss the final appended word. Incoming attributed
text may preserve already-faded colors; distinguish those from replacement
styles before refreshing original-color snapshots.
The wrapping host owns exactly one animator. Prop updates replace its ranges;
recycling releases its attributes and clock. There is no React-tag registry or
associated-object owner on a text view.
@@ -1,281 +0,0 @@
package sh.paseo.wordstream
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Typeface
import android.text.Editable
import android.text.Spannable
import android.text.SpannableString
import android.text.style.BackgroundColorSpan
import android.util.TypedValue
import android.view.Choreographer
import android.view.View
import android.view.ViewGroup
import android.widget.TextView
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import org.junit.Assert.*
import org.junit.Test
import org.junit.runner.RunWith
import kotlin.math.ceil
import kotlin.math.floor
@RunWith(AndroidJUnit4::class)
class WordFadeTest {
@Test fun replacementUsesOnlyTheCurrentTransactionsRangesAndStartTimes() = withSurface("old word") { s ->
s.host.ranges = listOf(word(0, 3, 200))
assertHidden(s.view, s.render(0), 0, 3)
s.view.text = SpannableString("old word new")
val baseline = bitmap(s.view)
s.host.ranges = listOf(word(9, 12, 200))
val beforeStart = s.render(200)
assertRegionEquals(s.view, baseline, beforeStart, 0, 9)
assertHidden(s.view, beforeStart, 9, 12)
assertTrue(brightness(s.render(230), bounds(s.view, 9, 10)) > 0)
}
@Test fun observingGrowthPreservesTheReadOnlyNativeTextBuffer() = withSurface("history ") { s ->
s.view.setTextIsSelectable(true)
assertFalse(s.view.text is Editable)
s.view.text = SpannableString("history word ")
s.host.ranges = listOf(word(8, 12))
s.render(70)
assertFalse("animation must not switch RN text into an editable buffer", s.view.text is Editable)
}
@Test fun nativeFadesKeepEmojiAndCombiningMarksTogether() {
withSurface("e\u0301e\u0301") { s ->
val baseline = bitmap(s.view)
s.host.ranges = listOf(word(0, 4, span = 120))
val middle = s.render(60)
val first = bounds(s.view, 0, 2)
for (y in 0 until baseline.height) for (x in first) {
val expected = Color.red(baseline.getPixel(x, y)) * 0.4
assertEquals("base letter and accent must share opacity", expected, Color.red(middle.getPixel(x, y)).toDouble(), 3.0)
}
assertHidden(s.view, middle, 2, 4)
assertTrue(baseline.sameAs(s.render(270)))
}
withSurface("👨‍👩‍👧‍👦 cafe\u0301") { s ->
val baseline = bitmap(s.view)
val width = s.view.layout.getLineWidth(0)
s.host.ranges = listOf(word(0, s.view.text.length))
s.render(70)
assertEquals(width, s.view.layout.getLineWidth(0), 0f)
assertTrue(baseline.sameAs(s.render(190)))
assertEquals("👨‍👩‍👧‍👦 cafe\u0301", s.view.text.toString())
}
}
@Test fun appendedWordsFadeWhenTextReplacementInvalidatesTheLayout() = withSurface("history ") { s ->
s.view.layoutParams = ViewGroup.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT)
s.view.text = SpannableString("history final words ")
assertTrue("fixture must require a new layout", s.view.isLayoutRequested)
s.host.ranges = listOf(word(8, 13), word(14, 19, 40))
// Commit ranges while layout is invalid, then perform the normal view
// traversal without resending either text or range props.
s.host.viewTreeObserver.dispatchOnPreDraw()
measure(s.view)
val first = s.render(0)
assertHidden(s.view, first, 8, 19)
val settled = s.render(230)
s.host.reset()
assertRegionEquals(s.view, settled, first, 0, 8)
assertTrue(bitmap(s.view).sameAs(settled))
}
@Test fun inlineCodeBackgroundNeverFlashesBrighterDuringFade() = withSurface("MMMMMM") { s ->
(s.view.text as Spannable).setSpan(BackgroundColorSpan(Color.DKGRAY), 0, 6, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
val before = bitmap(s.view)
s.host.ranges = listOf(word(0, 6))
val during = s.render(70)
assertFalse("mid-fade text must differ from its settled baseline", before.sameAs(during))
for (y in 0 until before.height) for (x in 0 until before.width) {
assertTrue("fade must not brighten inline-code background at $x,$y", Color.red(during.getPixel(x, y)) <= Color.red(before.getPixel(x, y)))
}
assertTrue(before.sameAs(s.render(190)))
}
@Test fun aRemountNeverRestartsAnExpiredWord() = withSurface("history next") { s ->
val baseline = bitmap(s.view)
s.clock.advanceTo(200)
s.host.ranges = listOf(word(0, 7), word(8, 12, 200))
val frame = s.render(200)
assertRegionEquals(s.view, baseline, frame, 0, 8)
assertHidden(s.view, frame, 8, 12)
s.host.reset()
s.host.removeView(s.view)
s.host.addView(s.view)
s.host.ranges = listOf(word(0, 7), word(8, 12, 200))
assertTrue("remount must resume the same frame", frame.sameAs(s.render(200)))
}
@Test fun aPropUpdatedBeforeChildTextAppliesBeforeDrawing() = withSurface("history ") { s ->
val history = bitmap(s.view)
s.host.ranges = listOf(word(8, 13))
assertTrue(history.sameAs(s.render(0)))
s.view.text = SpannableString("history final")
val baseline = bitmap(s.view)
val first = s.render(0)
assertRegionEquals(s.view, baseline, first, 0, 8)
assertHidden(s.view, first, 8, 13)
assertTrue(baseline.sameAs(s.render(190)))
}
@Test fun fadeMovesFromLeftToRightWithoutChangingTextLayout() = withSurface("MMMMMM") { s ->
val baseline = bitmap(s.view)
val width = s.view.layout.getLineWidth(0)
s.host.ranges = listOf(word(0, 6, span = 120))
assertHidden(s.view, s.render(0), 0, 6)
val middle = s.render(100)
assertTrue("left half must be more visible", brightness(middle, bounds(s.view, 0, 3)) > brightness(middle, bounds(s.view, 3, 6)))
assertTrue("settled rendering must match baseline", baseline.sameAs(s.render(270)))
assertEquals("MMMMMM", s.view.text.toString())
assertEquals(1, s.view.layout.lineCount)
assertEquals(width, s.view.layout.getLineWidth(0), 0f)
}
@Test fun bindingPreservesHistoryAndOnlyAnimatesAppendedWords() = withSurface("history ") { s ->
val history = bitmap(s.view)
assertTrue(history.sameAs(s.render(0)))
s.view.text = SpannableString("history one two ")
measure(s.view)
val baseline = bitmap(s.view)
s.host.ranges = listOf(word(8, 11), word(12, 15, 40))
val first = s.render(0)
assertRegionEquals(s.view, baseline, first, 0, 8)
assertHidden(s.view, first, 8, 15)
s.host.reset()
assertTrue(baseline.sameAs(s.render(0)))
assertFalse("reset must release scheduled animation work", s.clock.hasPendingFrame)
}
@Test fun consecutiveWordsFormOneFrontThatNeverRestartsAtAWordBoundary() = withSurface("MM MMMMMMMM MMM") { s ->
val baseline = bitmap(s.view)
s.host.ranges = listOf(word(0, 2, 0, 60), word(3, 11, 60, 60), word(12, 15, 120, 60))
val letters = s.view.text.indices.filter { s.view.text[it] != ' ' }
for (time in 0L..330L step 10) {
val frame = s.render(time)
val opacity = letters.map { index ->
val region = bounds(s.view, index, index + 1)
brightness(frame, region).toDouble() / brightness(baseline, region)
}
for (index in 1 until opacity.size) {
assertTrue("front reversed at character $index at ${time}ms", opacity[index - 1] + 0.01 >= opacity[index])
}
}
assertTrue(baseline.sameAs(s.render(330)))
}
@Test fun recyclingWithCopiedTextLeavesOnlyTheNewRanges() = withSurface("old word") { s ->
val baseline = bitmap(s.view)
s.host.ranges = listOf(word(0, 8))
s.render(0)
val copied = SpannableString(s.view.text)
s.host.reset()
s.host.removeView(s.view)
s.view.text = copied
s.host.addView(s.view)
s.host.ranges = listOf(word(4, 8))
val first = s.render(0)
assertRegionEquals(s.view, baseline, first, 0, 4)
assertHidden(s.view, first, 4, 8)
assertTrue(baseline.sameAs(s.render(190)))
}
@Test fun rightToLeftTextFadesFromTheNativeVisualLeft() = withSurface("שלום") { s ->
val baseline = bitmap(s.view)
s.host.ranges = listOf(word(0, 4, span = 80))
val frame = s.render(30)
val letters = s.view.text.indices.sortedBy { bounds(s.view, it, it + 1).first }
val left = bounds(s.view, letters.first(), letters.first() + 1)
val right = bounds(s.view, letters.last(), letters.last() + 1)
assertTrue("leftmost letter must have started", brightness(frame, left) > 0)
assertEquals("rightmost letter must still be hidden", 0L, brightness(frame, right))
assertTrue(baseline.sameAs(s.render(230)))
}
@Test fun settledHostStopsRequestingFramesAndMatchesItsBaseline() = withSurface("settled word") { s ->
val baseline = bitmap(s.view)
s.host.ranges = listOf(word(0, 7), word(8, 12))
s.render(189)
assertTrue("unsettled run must still request frames", s.clock.hasPendingFrame)
assertTrue(baseline.sameAs(s.render(190)))
assertFalse("settled host must release scheduled animation work", s.clock.hasPendingFrame)
}
private fun word(start: Int, end: Int, at: Long = 0, span: Long = 40) = WordRange().apply {
this.start = start; this.end = end; startedAt = at.toDouble(); spanMs = span.toDouble()
}
private class TestFrameClock : WordFadeFrameClock {
private var now = 0L
private val callbacks = mutableSetOf<Choreographer.FrameCallback>()
val hasPendingFrame get() = callbacks.isNotEmpty()
override fun currentTimeMillis() = now
override fun postFrameCallback(callback: Choreographer.FrameCallback) { callbacks.add(callback) }
override fun removeFrameCallback(callback: Choreographer.FrameCallback) { callbacks.remove(callback) }
fun advanceTo(time: Long) {
require(time >= now)
now = time
val frame = callbacks.toList()
callbacks.clear()
frame.forEach { it.doFrame(time * 1_000_000) }
}
}
private class Surface(val view: TextView, val clock: TestFrameClock, val host: WordFadeHost) {
fun render(time: Long): Bitmap {
host.viewTreeObserver.dispatchOnPreDraw()
clock.advanceTo(time)
return bitmap(view)
}
}
private fun withSurface(text: String, block: (Surface) -> Unit) =
InstrumentationRegistry.getInstrumentation().runOnMainSync {
val context = InstrumentationRegistry.getInstrumentation().targetContext
val view = TextView(context).apply {
layoutParams = ViewGroup.LayoutParams(900, ViewGroup.LayoutParams.WRAP_CONTENT)
// Pixel units keep every fixture on one line at any density.
setTextSize(TypedValue.COMPLEX_UNIT_PX, 48f)
typeface = Typeface.MONOSPACE
setTextColor(Color.WHITE)
setBackgroundColor(Color.BLACK)
setPadding(0, 0, 0, 0)
setText(SpannableString(text), TextView.BufferType.SPANNABLE)
measure(this)
}
val clock = TestFrameClock()
val host = WordFadeHost(context, clock).apply { addView(view) }
try { block(Surface(view, clock, host)) } finally { host.reset(); host.removeView(view) }
}
companion object {
private fun measure(view: TextView) {
view.measure(View.MeasureSpec.makeMeasureSpec(900, View.MeasureSpec.EXACTLY), View.MeasureSpec.makeMeasureSpec(400, View.MeasureSpec.AT_MOST))
view.layout(0, 0, view.measuredWidth, view.measuredHeight)
}
private fun bitmap(view: TextView): Bitmap = Bitmap.createBitmap(view.width, view.height, Bitmap.Config.ARGB_8888).also { view.draw(Canvas(it)) }
private fun bounds(view: TextView, start: Int, end: Int): IntRange {
require(view.layout.lineCount == 1) { "fixture text must fit on one line: ${view.text}" }
val a = view.layout.getPrimaryHorizontal(start)
val b = view.layout.getPrimaryHorizontal(end)
return floor(minOf(a, b)).toInt().coerceAtLeast(0) until ceil(maxOf(a, b)).toInt().coerceAtMost(view.width)
}
private fun brightness(bitmap: Bitmap, region: IntRange): Long {
var total = 0L
for (y in 0 until bitmap.height) for (x in region) total += Color.red(bitmap.getPixel(x, y))
return total
}
private fun assertHidden(view: TextView, bitmap: Bitmap, start: Int, end: Int) {
assertEquals("new text must not flash before its start: text=${view.text} layout=${view.layout.text} region=${bounds(view, start, end)} columns=${(start until end).map { brightness(bitmap, bounds(view, it, it + 1)) }}", 0L, brightness(bitmap, bounds(view, start, end)))
}
private fun assertRegionEquals(view: TextView, expected: Bitmap, actual: Bitmap, start: Int, end: Int) {
for (y in 0 until expected.height) for (x in bounds(view, start, end)) {
assertEquals("settled text changed at $x,$y", expected.getPixel(x, y), actual.getPixel(x, y))
}
}
}
}
@@ -1,35 +0,0 @@
package sh.paseo.wordstream
import android.text.TextPaint
import android.text.style.CharacterStyle
/** Wall-clock milliseconds shared by every span on a surface; advanced once per frame. */
internal class FadeClock {
var now: Long = 0L
}
/**
* Software Mansion's alpha span with an absolute start time. Every grapheme
* uses the same envelope, so opacity along the line is decided only by the
* order of start times: a single front moving in reading order.
*/
internal class FadeInSpan(private val clock: FadeClock, val startAt: Long) : CharacterStyle() {
val endsAt: Long get() = startAt + DURATION_MS
override fun updateDrawState(tp: TextPaint) {
val alpha = ((clock.now - startAt).toFloat() / DURATION_MS).coerceIn(0f, 1f)
tp.color = multiplyAlpha(tp.color, alpha)
tp.underlineColor = multiplyAlpha(tp.underlineColor, alpha)
}
private fun multiplyAlpha(color: Int, alpha: Float): Int {
if (alpha >= 1f) return color
if (alpha <= 0f) return color and 0x00FFFFFF
val a = ((color ushr 24) * alpha).toInt()
return (a shl 24) or (color and 0x00FFFFFF)
}
companion object {
const val DURATION_MS = 150L
}
}
@@ -1,128 +0,0 @@
package sh.paseo.wordstream
import android.animation.ValueAnimator
import android.icu.text.BreakIterator
import android.text.Spannable
import android.view.Choreographer
import android.widget.TextView
import java.lang.ref.WeakReference
import java.text.Bidi
/**
* Adapted from Software Mansion's TailFadeInAnimator; see UPSTREAM.md.
*
* One clock per surface. A released word is a run of graphemes whose start
* times are spread across the interval the word owns before the next word
* follows; the next word starts where this one ends. All spans read the same
* clock, so a frame paints one monotone front instead of a fade per word.
*/
internal class TailFadeInAnimator(
textView: TextView,
private val frameClock: WordFadeFrameClock,
) {
private data class SpanRange(val span: FadeInSpan, val start: Int, val end: Int)
private class ActiveFade(val spans: List<SpanRange>, val endsAt: Long)
private val viewRef = WeakReference(textView)
private val activeAnimations = mutableListOf<ActiveFade>()
private val characters = BreakIterator.getCharacterInstance()
private val clock = FadeClock()
private var scheduled = false
private val frame = Choreographer.FrameCallback { tick() }
private var buffer: Spannable? = null
private var ranges: List<WordRange>? = null
private val isIdle: Boolean get() = activeAnimations.isEmpty()
fun receive(next: List<WordRange>) {
val text = viewRef.get()?.text as? Spannable ?: return
if (buffer === text && ranges === next) return
cancelAll()
buffer = text
ranges = next
// RN may copy CharacterStyles while replacing its read-only buffer.
text.getSpans(0, text.length, FadeInSpan::class.java).forEach { text.removeSpan(it) }
if (!ValueAnimator.areAnimatorsEnabled()) return
val now = frameClock.currentTimeMillis()
clock.now = now
for (range in next) {
if (range.start < 0 || range.end > text.length || range.end <= range.start) continue
if (now >= range.startedAt + range.spanMs + FadeInSpan.DURATION_MS) continue
animateRange(text, range.start, range.end, range.startedAt, range.spanMs)
}
if (!isIdle) schedule()
}
private fun animateRange(text: Spannable, start: Int, end: Int, startedAt: Double, spanMs: Double) {
// Grapheme boundaries keep emoji and combining marks together.
val word = text.subSequence(start, end).toString()
characters.setText(word)
val ranges = mutableListOf<IntRange>()
var from = characters.first()
var to = characters.next()
while (to != BreakIterator.DONE) {
ranges.add(from until to)
from = to
to = characters.next()
}
// RN can invalidate Layout until TextView.onDraw. Alpha spans need only
// native Unicode visual order, so they also work before that first draw.
val bidi = Bidi(word, Bidi.DIRECTION_DEFAULT_LEFT_TO_RIGHT)
val levels = ByteArray(ranges.size) { bidi.getLevelAt(ranges[it].first).toByte() }
val visualOrder = Array<Any>(ranges.size) { ranges[it] }
Bidi.reorderVisually(levels, 0, visualOrder, 0, visualOrder.size)
val spans = visualOrder.mapIndexed { index, item ->
val range = item as IntRange
val startAt = (startedAt + spanMs * index / visualOrder.size).toLong()
val span = FadeInSpan(clock, startAt)
val spanStart = start + range.first
val spanEnd = start + range.last + 1
text.setSpan(span, spanStart, spanEnd, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
SpanRange(span, spanStart, spanEnd)
}
activeAnimations.add(ActiveFade(spans, (startedAt + spanMs).toLong() + FadeInSpan.DURATION_MS))
}
private fun schedule() {
if (scheduled) return
scheduled = true
frameClock.postFrameCallback(frame)
}
private fun tick() {
scheduled = false
if (activeAnimations.isEmpty()) return
val view = viewRef.get()
if (view == null) {
cancelAll()
return
}
paint(frameClock.currentTimeMillis())
view.invalidate()
if (activeAnimations.isNotEmpty()) schedule()
}
/** Advance the shared clock, keep spans bound to the current text, and drop settled runs. */
private fun paint(now: Long) {
clock.now = now
val current = buffer ?: return
for (fade in activeAnimations.toList()) {
if (now >= fade.endsAt) cleanup(fade, current)
}
}
private fun cleanup(fade: ActiveFade, current: Spannable?) {
if (!activeAnimations.remove(fade)) return
fade.spans.forEach { current?.removeSpan(it.span) }
}
fun cancelAll() {
val current = buffer
buffer = null
ranges = null
activeAnimations.toList().forEach { cleanup(it, current) }
if (scheduled) {
frameClock.removeFrameCallback(frame)
scheduled = false
}
}
}
@@ -1,20 +0,0 @@
package sh.paseo.wordstream
import android.view.Choreographer
/** Frame source owned by the native host; timestamps use the range props' epoch. */
interface WordFadeFrameClock {
fun currentTimeMillis(): Long
fun postFrameCallback(callback: Choreographer.FrameCallback)
fun removeFrameCallback(callback: Choreographer.FrameCallback)
}
internal object AndroidWordFadeFrameClock : WordFadeFrameClock {
override fun currentTimeMillis() = System.currentTimeMillis()
override fun postFrameCallback(callback: Choreographer.FrameCallback) {
Choreographer.getInstance().postFrameCallback(callback)
}
override fun removeFrameCallback(callback: Choreographer.FrameCallback) {
Choreographer.getInstance().removeFrameCallback(callback)
}
}
@@ -1,84 +0,0 @@
package sh.paseo.wordstream
import android.content.Context
import android.view.View
import android.view.ViewTreeObserver
import android.widget.TextView
import android.view.ViewGroup
import expo.modules.kotlin.modules.Module
import expo.modules.kotlin.modules.ModuleDefinition
import expo.modules.kotlin.records.Record
import expo.modules.kotlin.records.Field
class WordRange : Record {
@Field var start: Int = 0
@Field var end: Int = 0
@Field var startedAt: Double = 0.0
@Field var spanMs: Double = 0.0
}
class WordStreamModule : Module() {
override fun definition() = ModuleDefinition {
Name("PaseoWordStream")
View(WordFadeHost::class) {
Prop("ranges") { view: WordFadeHost, ranges: List<WordRange> -> view.ranges = ranges }
OnViewDestroys { view: WordFadeHost -> view.reset() }
}
}
}
/** Fabric owns child layout and commits the text and ranges before pre-draw. */
class WordFadeHost @JvmOverloads constructor(
context: Context,
private val frameClock: WordFadeFrameClock = AndroidWordFadeFrameClock,
) : ViewGroup(context) {
override fun onLayout(changed: Boolean, left: Int, top: Int, right: Int, bottom: Int) {}
private var animator: TailFadeInAnimator? = null
var ranges: List<WordRange> = emptyList()
set(value) {
field = value
animator?.cancelAll()
applyRanges()
invalidate()
}
private val beforeDraw = ViewTreeObserver.OnPreDrawListener { applyRanges(); true }
private var observer = viewTreeObserver
init { observer.addOnPreDrawListener(beforeDraw) }
override fun onViewAdded(child: View) {
super.onViewAdded(child)
require(child is TextView) { "WordFadeHost requires a single root TextView" }
require(childCount == 1) { "WordFadeHost requires a single child" }
animator = TailFadeInAnimator(child, frameClock)
applyRanges()
}
override fun onViewRemoved(child: View) {
animator?.cancelAll()
animator = null
super.onViewRemoved(child)
}
private fun applyRanges() { animator?.receive(ranges) }
override fun onAttachedToWindow() {
super.onAttachedToWindow()
observer = viewTreeObserver
observer.removeOnPreDrawListener(beforeDraw)
observer.addOnPreDrawListener(beforeDraw)
applyRanges()
}
override fun onDetachedFromWindow() {
if (observer.isAlive) observer.removeOnPreDrawListener(beforeDraw)
animator?.cancelAll()
super.onDetachedFromWindow()
}
fun reset() {
ranges = emptyList()
animator?.cancelAll()
}
}
@@ -1,9 +0,0 @@
{
"platforms": ["android", "apple"],
"android": {
"modules": ["sh.paseo.wordstream.WordStreamModule"]
},
"apple": {
"modules": ["WordStreamModule"]
}
}
@@ -1,16 +0,0 @@
Pod::Spec.new do |s|
s.name = 'PaseoWordStream'
s.version = '0.1.0'
s.summary = 'Native word streaming for Paseo'
s.description = 'Native word streaming for Paseo'
s.license = { :type => 'MIT', :file => '../LICENSE' }
s.author = 'Paseo'
s.homepage = 'https://paseo.sh'
s.platforms = { :ios => '15.1' }
s.swift_version = '5.9'
s.source = { :path => '.' }
s.static_framework = true
s.dependency 'ExpoModulesCore'
s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' }
s.source_files = ['*.swift', 'internal/*.swift']
end
@@ -1,89 +0,0 @@
import ExpoModulesCore
import UIKit
struct WordRangeRecord: Record {
@Field var start: Int = 0
@Field var end: Int = 0
@Field var startedAt: Double = 0
@Field var spanMs: Double = 0
}
public final class WordStreamModule: Module {
public func definition() -> ModuleDefinition {
Name("PaseoWordStream")
View(WordFadeHost.self) {
Prop("ranges") { (view: WordFadeHost, ranges: [WordRangeRecord]) in
view.ranges = ranges.map {
WordFadeRange(start: $0.start, end: $0.end, startedAt: $0.startedAt, spanMs: $0.spanMs)
}
}
}
}
}
final class WordFadeHost: ExpoView {
private weak var textView: UITextView?
private var animator: TailFadeInAnimator?
var ranges: [WordFadeRange] = [] {
didSet {
animator?.receive(ranges: ranges)
setNeedsLayout()
}
}
override func layoutSubviews() {
super.layoutSubviews()
// RN's child owns geometry. Its drawRect installs the attributed string.
subviews.forEach { $0.layer.displayIfNeeded() }
bindTextView()
}
override func didAddSubview(_ subview: UIView) {
super.didAddSubview(subview)
setNeedsLayout()
}
override func willRemoveSubview(_ subview: UIView) {
animator?.close()
animator = nil
textView = nil
super.willRemoveSubview(subview)
}
override func didMoveToWindow() {
super.didMoveToWindow()
if window == nil {
animator?.close()
animator = nil
textView = nil
} else {
setNeedsLayout()
}
}
override func prepareForRecycle() {
ranges = []
animator?.close()
animator = nil
textView = nil
super.prepareForRecycle()
}
private func bindTextView() {
guard let child = Self.findTextView(self) else { return }
if child !== textView {
animator?.close()
textView = child
animator = TailFadeInAnimator(textView: child)
}
animator?.receive(ranges: ranges)
}
private static func findTextView(_ view: UIView) -> UITextView? {
if let text = view as? UITextView { return text }
for child in view.subviews {
if let text = findTextView(child) { return text }
}
return nil
}
}
@@ -1,172 +0,0 @@
import UIKit
import CoreText
/// `startedAt` is wall-clock milliseconds; the run's graphemes start fading
/// evenly across `spanMs`, and each takes the same 150 ms envelope.
struct WordFadeRange { let start: Int; let end: Int; let startedAt: Double; let spanMs: Double }
// Adapts Software Mansion's ENRMTailFadeInAnimator: snapshot foreground colors,
// update NSTextStorage from CADisplayLink, and release the clock when settled.
// One clock per surface; every glyph reads its own absolute start time, so the
// line paints as one monotone front rather than a fade per word.
final class TailFadeInAnimator: NSObject {
private struct Glyph {
let range: NSRange
var color: UIColor
var appliedColor: UIColor?
let start: CFTimeInterval
}
private struct Word {
let endsAt: CFTimeInterval
var glyphs: [Glyph]
}
private weak var textView: UITextView?
private var ranges: [WordFadeRange] = []
private var words: [Word] = []
private var observer: NSObjectProtocol?
private var displayLink: CADisplayLink?
private var applying = false
private static let duration: CFTimeInterval = 150
var isAnimating: Bool { !words.isEmpty }
init(textView: UITextView) {
self.textView = textView
super.init()
observer = NotificationCenter.default.addObserver(
forName: NSTextStorage.didProcessEditingNotification,
object: textView.textStorage,
queue: nil
) { [weak self] _ in self?.refresh() }
}
func close() {
if let observer {
NotificationCenter.default.removeObserver(observer)
self.observer = nil
}
ranges = []
restore()
stopClock()
}
deinit {
if let observer { NotificationCenter.default.removeObserver(observer) }
displayLink?.invalidate()
}
func receive(ranges: [WordFadeRange]) {
self.ranges = ranges
refresh()
}
private func refresh() {
guard !applying, let textView else { return }
// Restore only colors we applied. A Fabric replacement may already carry
// fresh styles; those are the next snapshot's source of truth.
restore()
let storage = textView.textStorage
let now = Date().timeIntervalSince1970 * 1000
for range in ranges {
guard range.start >= 0, range.end <= storage.length, range.end > range.start,
now < range.startedAt + range.spanMs + Self.duration else { continue }
words.append(Word(endsAt: range.startedAt + range.spanMs + Self.duration,
glyphs: snapshot(NSRange(location: range.start, length: range.end - range.start),
storage: storage, started: range.startedAt, span: range.spanMs)))
}
apply(now: now)
if words.isEmpty { stopClock(); return }
if displayLink == nil {
let link = CADisplayLink(target: ClockTarget(self), selector: #selector(ClockTarget.step(_:)))
link.preferredFramesPerSecond = 0
link.add(to: .main, forMode: .common)
displayLink = link
}
}
private func snapshot(_ range: NSRange, storage: NSTextStorage, started: CFTimeInterval, span: CFTimeInterval) -> [Glyph] {
let word = storage.attributedSubstring(from: range)
let line = CTLineCreateWithAttributedString(word)
let string = word.string as NSString
var ranges: [(NSRange, CGFloat)] = []
var offset = 0
while offset < string.length {
let cluster = string.rangeOfComposedCharacterSequence(at: offset)
let left = CTLineGetOffsetForStringIndex(line, cluster.location, nil)
let right = CTLineGetOffsetForStringIndex(line, NSMaxRange(cluster), nil)
ranges.append((cluster, min(left, right)))
offset = NSMaxRange(cluster)
}
ranges.sort { $0.1 < $1.1 }
return ranges.enumerated().map { index, entry in
let absolute = NSRange(location: range.location + entry.0.location, length: entry.0.length)
return Glyph(range: absolute,
color: storage.attribute(.foregroundColor, at: absolute.location, effectiveRange: nil) as? UIColor ?? .label,
appliedColor: nil,
start: started + span * CFTimeInterval(index) / CFTimeInterval(ranges.count))
}
}
fileprivate func step(_ link: CADisplayLink) {
guard let textView, textView.window != nil else {
restore()
stopClock()
return
}
// RNUITextView assigns its attributed text in drawRect, even when bounds
// did not change. Flush that assignment before painting this frame.
textView.superview?.layer.displayIfNeeded()
apply(now: Date().timeIntervalSince1970 * 1000)
if words.isEmpty { stopClock() }
}
private func apply(now: CFTimeInterval) {
guard let storage = textView?.textStorage else { return }
applying = true
storage.beginEditing()
for w in words.indices {
for g in words[w].glyphs.indices {
let glyph = words[w].glyphs[g]
guard NSMaxRange(glyph.range) <= storage.length else { continue }
let alpha = CGFloat(min(1, max(0, (now - glyph.start) / Self.duration)))
let color = glyph.color.withAlphaComponent(glyph.color.cgColor.alpha * alpha)
words[w].glyphs[g].appliedColor = color
storage.addAttribute(.foregroundColor,
value: color,
range: glyph.range)
}
}
storage.endEditing()
applying = false
words.removeAll { now >= $0.endsAt }
}
private func restore() {
guard let storage = textView?.textStorage else { words.removeAll(); return }
applying = true
storage.beginEditing()
for word in words {
for glyph in word.glyphs where NSMaxRange(glyph.range) <= storage.length {
let incoming = storage.attribute(.foregroundColor, at: glyph.range.location, effectiveRange: nil) as? UIColor
if incoming == glyph.appliedColor {
storage.addAttribute(.foregroundColor, value: glyph.color, range: glyph.range)
}
}
}
storage.endEditing()
applying = false
words.removeAll()
}
private func stopClock() {
displayLink?.invalidate()
displayLink = nil
}
}
private final class ClockTarget: NSObject {
weak var animator: TailFadeInAnimator?
init(_ animator: TailFadeInAnimator) { self.animator = animator }
@objc func step(_ link: CADisplayLink) { animator?.step(link) }
}
@@ -1,197 +0,0 @@
import XCTest
import UIKit
final class WordFadeTests: XCTestCase {
private var window: UIWindow!
private var text: UITextView!
private var animator: TailFadeInAnimator!
override func setUp() {
super.setUp()
window = UIWindow(frame: CGRect(x: 0, y: 0, width: 390, height: 844))
let controller = UIViewController()
window.rootViewController = controller
text = UITextView(frame: CGRect(x: 10, y: 30, width: 300, height: 400))
text.font = .systemFont(ofSize: 20)
text.isEditable = false
text.isSelectable = true
controller.view.addSubview(text)
window.makeKeyAndVisible()
}
override func tearDown() {
animator?.close()
animator = nil
window.isHidden = true
window = nil
super.tearDown()
}
private func content(_ value: String) -> NSAttributedString {
NSAttributedString(string: value, attributes: [.font: UIFont.systemFont(ofSize: 20), .foregroundColor: UIColor.label])
}
private func reveal(from start: Int) {
let matches = try! NSRegularExpression(pattern: "\\S+").matches(in: text.text, range: NSRange(location: start, length: text.textStorage.length - start))
animator.receive(ranges: matches.map {
WordFadeRange(start: $0.range.location, end: NSMaxRange($0.range), startedAt: Date().timeIntervalSince1970 * 1000, spanMs: 40)
})
}
func testRemountKeepsExpiredWordsOpaque() {
text.attributedText = content("old new")
animator = TailFadeInAnimator(textView: text)
let now = Date().timeIntervalSince1970 * 1000
animator.receive(ranges: [
WordFadeRange(start: 0, end: 3, startedAt: now - 200, spanMs: 40),
WordFadeRange(start: 4, end: 7, startedAt: now, spanMs: 40)
])
XCTAssertEqual(alpha(0), 1)
XCTAssertLessThan(alpha(4), 0.1)
}
private func alpha(_ index: Int) -> CGFloat {
(text.textStorage.attribute(.foregroundColor, at: index, effectiveRange: nil) as! UIColor).cgColor.alpha
}
private func after(_ interval: TimeInterval, _ assertion: @escaping () -> Void) {
let done = expectation(description: "native display frames")
DispatchQueue.main.asyncAfter(deadline: .now() + interval) { assertion(); done.fulfill() }
wait(for: [done], timeout: 2)
}
func testHistoryIsPlainAndAppendedWordFadesLeftToRight() {
text.attributedText = content("History ")
animator = TailFadeInAnimator(textView: text)
XCTAssertFalse(animator.isAnimating)
text.attributedText = content("History abcdefgh ")
reveal(from: 8)
XCTAssertEqual(alpha(0), 1)
XCTAssertEqual(alpha(8), 0, accuracy: 0.05)
XCTAssertTrue(animator.isAnimating)
after(0.065) { XCTAssertGreaterThan(self.alpha(8), self.alpha(15)) }
after(0.2) {
XCTAssertEqual(self.alpha(15), 1)
XCTAssertFalse(self.animator.isAnimating)
XCTAssertEqual(self.text.text, "History abcdefgh ")
}
}
func testCompletionPreservesSelectionBackgroundAndOriginalAlpha() {
let color = UIColor.red.withAlphaComponent(0.7)
let value = NSAttributedString(string: "Native selection", attributes: [.font: UIFont.systemFont(ofSize: 20), .foregroundColor: color, .backgroundColor: UIColor.darkGray])
text.attributedText = value
text.selectedRange = NSRange(location: 0, length: 6)
animator = TailFadeInAnimator(textView: text)
reveal(from: 0)
XCTAssertEqual(text.textStorage.attribute(.backgroundColor, at: 0, effectiveRange: nil) as? UIColor, .darkGray)
after(0.25) {
XCTAssertEqual(self.alpha(0), 0.7, accuracy: 0.001)
XCTAssertEqual(self.text.selectedRange, NSRange(location: 0, length: 6))
XCTAssertEqual(self.text.attributedText, value)
}
}
func testGraphemeClustersAndAppendDuringFadeKeepOriginalColors() {
text.attributedText = content("")
animator = TailFadeInAnimator(textView: text)
text.textStorage.append(content("👨‍👩‍👧‍👦 café "))
reveal(from: 0)
let next = text.textStorage.length
text.textStorage.append(content("more "))
reveal(from: next)
XCTAssertEqual(alpha(0), alpha(1))
after(0.25) {
for index in 0..<self.text.textStorage.length { XCTAssertEqual(self.alpha(index), 1) }
XCTAssertFalse(self.animator.isAnimating)
}
}
func testCurrentRangesReplaceEveryPreviousRangeAndPreserveNewStyles() {
text.attributedText = content("old word")
animator = TailFadeInAnimator(textView: text)
let now = Date().timeIntervalSince1970 * 1000
animator.receive(ranges: [WordFadeRange(start: 0, end: 3, startedAt: now, spanMs: 60)])
let color = UIColor.red.withAlphaComponent(0.7)
text.attributedText = NSAttributedString(string: "old word new", attributes: [.foregroundColor: color])
animator.receive(ranges: [WordFadeRange(start: 9, end: 12, startedAt: now + 200, spanMs: 60)])
XCTAssertEqual(alpha(0), 0.7, accuracy: 0.001)
XCTAssertEqual(alpha(9), 0)
animator.receive(ranges: [])
XCTAssertEqual(alpha(9), 0.7, accuracy: 0.001)
XCTAssertFalse(animator.isAnimating)
animator.close()
text.attributedText = content("recycled")
animator = TailFadeInAnimator(textView: text)
animator.receive(ranges: [WordFadeRange(start: 4, end: 8, startedAt: now + 200, spanMs: 60)])
XCTAssertEqual(alpha(0), 1)
XCTAssertEqual(alpha(4), 0)
}
func testRightToLeftTextStillFadesFromVisualLeftToRight() {
for value in ["שלום", "العربية"] {
text.attributedText = content(value)
text.layoutIfNeeded()
let ordered = (0..<text.textStorage.length).sorted { lhs, rhs in
let left = text.position(from: text.beginningOfDocument, offset: lhs)!
let right = text.position(from: text.beginningOfDocument, offset: rhs)!
return text.caretRect(for: left).minX < text.caretRect(for: right).minX
}
animator = TailFadeInAnimator(textView: text)
reveal(from: 0)
after(0.065) {
XCTAssertGreaterThan(self.alpha(ordered.first!), self.alpha(ordered.last!), value)
}
animator.close()
}
}
func testDetachedSurfaceSettlesAndCanAnimateAfterReattachment() {
text.attributedText = content("Visible word")
animator = TailFadeInAnimator(textView: text)
reveal(from: 0)
text.removeFromSuperview()
after(0.2) {
XCTAssertFalse(self.animator.isAnimating)
XCTAssertEqual(self.alpha(0), 1)
}
window.rootViewController!.view.addSubview(text)
let next = text.textStorage.length
text.textStorage.append(content(" appended"))
reveal(from: next)
XCTAssertTrue(animator.isAnimating)
after(0.2) {
XCTAssertFalse(self.animator.isAnimating)
XCTAssertEqual(self.alpha(self.text.textStorage.length - 1), 1)
}
}
func testConsecutiveWordsFormOneFrontAcrossTheWordBoundary() {
text.attributedText = content("one two")
animator = TailFadeInAnimator(textView: text)
let now = Date().timeIntervalSince1970 * 1000
animator.receive(ranges: [
WordFadeRange(start: 0, end: 3, startedAt: now, spanMs: 60),
WordFadeRange(start: 4, end: 7, startedAt: now + 60, spanMs: 60)
])
after(0.05) {
let alphas = [0, 1, 2, 4, 5, 6].map { self.alpha($0) }
for index in 1..<alphas.count {
XCTAssertLessThanOrEqual(alphas[index], alphas[index - 1], "opacity must not increase in reading order")
}
XCTAssertGreaterThan(self.alpha(2), self.alpha(4), "the next word must not overtake the end of the previous word")
}
}
func testReplacementAndCloseReleaseOldRanges() {
text.attributedText = content("Old text")
animator = TailFadeInAnimator(textView: text)
text.attributedText = content("Replacement")
animator.receive(ranges: [])
XCTAssertFalse(animator.isAnimating)
XCTAssertEqual(alpha(0), 1)
animator.close()
text.attributedText = content("Replacement appended")
XCTAssertEqual(alpha(12), 1)
}
}
@@ -1,40 +0,0 @@
# Run on macOS: ruby run.rb <output-directory> <simulator-UDID>
require 'xcodeproj'
require 'fileutils'
output, device = ARGV
abort 'Usage: ruby run.rb <output-directory> <simulator-UDID>' unless output && device
output = File.expand_path(output)
FileUtils.mkdir_p(output)
project = Xcodeproj::Project.new(File.join(output, 'WordFadeNative.xcodeproj'))
app = project.new_target(:application, 'WordFadeHost', :ios, '15.1')
tests = project.new_target(:unit_test_bundle, 'WordFadeTests', :ios, '15.1')
host_source = File.join(output, 'Host.swift')
File.write(host_source, "import UIKit\n@main final class Host: UIResponder, UIApplicationDelegate {}\n")
app.add_file_references([project.main_group.new_file(host_source)])
tests.add_file_references([
project.main_group.new_file(File.expand_path('../internal/TailFadeInAnimator.swift', __dir__)),
project.main_group.new_file(File.join(__dir__, 'WordFadeTests.swift'))
])
tests.add_dependency(app)
[app, tests].each do |target|
target.build_configurations.each do |config|
config.build_settings.merge!({
'SWIFT_VERSION' => '5.9', 'GENERATE_INFOPLIST_FILE' => 'YES',
'PRODUCT_BUNDLE_IDENTIFIER' => "sh.paseo.wordstream.#{target.name}",
'CODE_SIGNING_ALLOWED' => 'NO', 'TARGETED_DEVICE_FAMILY' => '1,2'
})
end
end
tests.build_configurations.each do |config|
config.build_settings['TEST_HOST'] = '$(BUILT_PRODUCTS_DIR)/WordFadeHost.app/WordFadeHost'
config.build_settings['BUNDLE_LOADER'] = '$(TEST_HOST)'
end
project.save
scheme = Xcodeproj::XCScheme.new
scheme.add_build_target(app)
scheme.add_test_target(tests)
scheme.set_launch_target(app)
scheme.test_action.build_configuration = 'Release'
scheme.save_as(project.path, 'WordFadeNative')
exec 'xcodebuild', '-project', project.path.to_s, '-scheme', 'WordFadeNative', '-destination', "platform=iOS Simulator,id=#{device}", '-derivedDataPath', File.join(output, 'derived'), '-resultBundlePath', File.join(output, 'results.xcresult'), 'test'
-1
View File
@@ -130,7 +130,6 @@
"tiny-invariant": "^1.3.3",
"turndown": "^7.2.4",
"turndown-plugin-gfm": "^1.0.2",
"unicode-segmenter": "0.17.3",
"use-sync-external-store": "^1.6.0",
"yaml": "^2.8.4",
"zod": "^4.4.3",
@@ -0,0 +1,277 @@
import { describe, expect, it } from "vitest";
import {
advanceTextReveal,
beginTextReveal,
clampToSafeRevealBoundary,
completeTextReveal,
computeRevealStep,
nextTextRevealFrame,
isTextRevealSettled,
retargetTextReveal,
TEXT_REVEAL_FRAME_INTERVAL_MS,
TEXT_REVEAL_HORIZON_MS,
visibleRevealedText,
} from "./text-reveal";
describe("computeRevealStep", () => {
it("reveals nothing when there is no backlog", () => {
expect(computeRevealStep({ backlog: 0, elapsedMs: 16 })).toBe(0);
expect(computeRevealStep({ backlog: -5, elapsedMs: 16 })).toBe(0);
});
it("drains the backlog over the horizon", () => {
const step = computeRevealStep({ backlog: 300, elapsedMs: TEXT_REVEAL_HORIZON_MS / 2 });
expect(step).toBe(150);
});
it("reveals the whole backlog once a frame covers the horizon", () => {
expect(computeRevealStep({ backlog: 42, elapsedMs: TEXT_REVEAL_HORIZON_MS })).toBe(42);
expect(computeRevealStep({ backlog: 42, elapsedMs: TEXT_REVEAL_HORIZON_MS * 10 })).toBe(42);
});
it("scales the rate with the backlog so a faster model reveals faster", () => {
const slow = computeRevealStep({ backlog: 20, elapsedMs: 16 });
const fast = computeRevealStep({ backlog: 200, elapsedMs: 16 });
expect(fast).toBeGreaterThan(slow);
});
it("always advances at least one character so the tail finishes", () => {
expect(computeRevealStep({ backlog: 1, elapsedMs: 1 })).toBe(1);
expect(computeRevealStep({ backlog: 2, elapsedMs: 0.01 })).toBe(1);
});
it("never overshoots the backlog", () => {
for (const backlog of [1, 3, 7, 50]) {
expect(computeRevealStep({ backlog, elapsedMs: 120 })).toBeLessThanOrEqual(backlog);
}
});
it("reveals nothing when no time has passed", () => {
expect(computeRevealStep({ backlog: 100, elapsedMs: 0 })).toBe(0);
expect(computeRevealStep({ backlog: 100, elapsedMs: -16 })).toBe(0);
});
it("converges within a bounded number of frames at 60Hz", () => {
let remaining = 500;
let frames = 0;
while (remaining > 0) {
remaining -= computeRevealStep({ backlog: remaining, elapsedMs: 16 });
frames += 1;
expect(frames).toBeLessThan(120);
}
expect(remaining).toBe(0);
});
it("keeps per-frame steps smooth for a bursty arrival pattern", () => {
// A lumpy arrival sequence: the daemon's coalescing window carries wildly
// different character counts depending on model throughput.
const arrivals = [4, 180, 6, 240, 2, 90, 300, 8];
const steps: number[] = [];
let backlog = 0;
for (const arrival of arrivals) {
backlog += arrival;
// ~60ms of frames between coalesced flushes.
for (let frame = 0; frame < 4; frame += 1) {
const step = computeRevealStep({ backlog, elapsedMs: 16 });
backlog -= step;
steps.push(step);
}
}
// Painting arrivals directly would swing from 2 to 300 characters in one
// paint. Pacing keeps the largest single paint far below the largest arrival.
expect(Math.max(...steps)).toBeLessThan(Math.max(...arrivals) / 2);
expect(Math.min(...steps)).toBeGreaterThan(0);
});
});
describe("clampToSafeRevealBoundary", () => {
it("leaves boundaries in plain text alone", () => {
expect(clampToSafeRevealBoundary("hello world", 5)).toBe(5);
});
it("clamps the ends", () => {
expect(clampToSafeRevealBoundary("hello", -3)).toBe(0);
expect(clampToSafeRevealBoundary("hello", 99)).toBe(5);
});
it("never splits a surrogate pair", () => {
// "a😀b" — the emoji occupies indices 1 and 2.
const text = "a😀b";
expect(clampToSafeRevealBoundary(text, 2)).toBe(1);
expect(clampToSafeRevealBoundary(text, 3)).toBe(3);
});
it("does not strand a combining mark", () => {
// "e" + combining acute; cutting at 1 would show a bare "e" then reflow.
const text = `éx`;
expect(clampToSafeRevealBoundary(text, 1)).toBe(0);
expect(clampToSafeRevealBoundary(text, 2)).toBe(2);
});
it("does not split an emoji ZWJ sequence", () => {
// Family emoji: person ZWJ person ZWJ child.
const family = "\u{1F468}‍\u{1F469}‍\u{1F467}";
const text = `${family}!`;
for (let index = 1; index < family.length; index += 1) {
expect(clampToSafeRevealBoundary(text, index)).toBe(0);
}
expect(clampToSafeRevealBoundary(text, family.length)).toBe(family.length);
});
it("does not split a skin tone modifier from its base", () => {
const text = "\u{1F44D}\u{1F3FD} nice";
expect(clampToSafeRevealBoundary(text, 2)).toBe(0);
expect(clampToSafeRevealBoundary(text, 4)).toBe(4);
});
it("does not strand a variation selector", () => {
const text = "❤️ done";
expect(clampToSafeRevealBoundary(text, 1)).toBe(0);
expect(clampToSafeRevealBoundary(text, 2)).toBe(2);
});
it("does not split extended grapheme clusters", () => {
for (const { text, boundaries } of [
{ text: "a🇺🇸b", boundaries: [0, 1, 5, 6] },
{ text: "a가b", boundaries: [0, 1, 3, 4] },
{ text: "aकःb", boundaries: [0, 1, 3, 4] },
]) {
for (let index = 1; index < text.length; index += 1) {
const expected = boundaries.toReversed().find((boundary) => boundary <= index);
expect(clampToSafeRevealBoundary(text, index)).toBe(expected);
}
}
});
});
describe("nextTextRevealFrame", () => {
it("caps a high-refresh frame clock at 60Hz", () => {
expect(nextTextRevealFrame(null, 0)).toEqual({
elapsedMs: TEXT_REVEAL_FRAME_INTERVAL_MS,
frameAtMs: 0,
});
expect(nextTextRevealFrame(0, 8)).toBeNull();
expect(nextTextRevealFrame(0, 17)).toEqual({
elapsedMs: 17,
frameAtMs: expect.closeTo(TEXT_REVEAL_FRAME_INTERVAL_MS, 5),
});
});
});
describe("text reveal state", () => {
it("reveals the first text it sees in full", () => {
const state = beginTextReveal("already here");
expect(visibleRevealedText(state)).toBe("already here");
expect(isTextRevealSettled(state)).toBe(true);
});
it("paces growth instead of painting it whole", () => {
let state = beginTextReveal("a");
state = retargetTextReveal(state, `a${"b".repeat(300)}`);
expect(visibleRevealedText(state)).toBe("a");
state = advanceTextReveal(state, 16);
const shown = visibleRevealedText(state);
expect(shown.length).toBeGreaterThan(1);
expect(shown.length).toBeLessThan(301);
});
it("catches up to the full text across frames", () => {
const full = `a${"b".repeat(200)}`;
let state = retargetTextReveal(beginTextReveal("a"), full);
for (let frame = 0; frame < 60 && !isTextRevealSettled(state); frame += 1) {
state = advanceTextReveal(state, 16);
}
expect(isTextRevealSettled(state)).toBe(true);
expect(visibleRevealedText(state)).toBe(full);
});
it("releases everything when the turn completes", () => {
const full = `a${"b".repeat(500)}`;
let state = advanceTextReveal(retargetTextReveal(beginTextReveal("a"), full), 16);
expect(visibleRevealedText(state)).not.toBe(full);
state = completeTextReveal(state);
expect(visibleRevealedText(state)).toBe(full);
});
it("only ever paints a prefix of the real text", () => {
const full = "the quick brown fox jumps over the lazy dog";
let state = retargetTextReveal(beginTextReveal("the "), full);
for (let frame = 0; frame < 20; frame += 1) {
expect(full.startsWith(visibleRevealedText(state))).toBe(true);
state = advanceTextReveal(state, 16);
}
});
it("clamps when the slot switches to a shorter message", () => {
const state = retargetTextReveal(beginTextReveal("a long streaming message"), "short");
expect(visibleRevealedText(state)).toBe("short");
});
it("never paints a lone surrogate while streaming emoji", () => {
const full = `progress ${"\u{1F680}".repeat(40)} done`;
let state = retargetTextReveal(beginTextReveal("progress "), full);
for (let frame = 0; frame < 200 && !isTextRevealSettled(state); frame += 1) {
const shown = visibleRevealedText(state);
// A stranded surrogate would make the painted prefix un-decodable.
expect(shown).toBe(Array.from(shown).join(""));
expect(full.startsWith(shown)).toBe(true);
state = advanceTextReveal(state, 16);
}
expect(visibleRevealedText(completeTextReveal(state))).toBe(full);
});
it("a long cluster cannot stall the reveal", () => {
// The rendered boundary backs up over this cluster, but the counter does not,
// so the reveal still finishes.
const full = `x${"́".repeat(40)}y`;
let state = retargetTextReveal(beginTextReveal("x"), full);
for (let frame = 0; frame < 200 && !isTextRevealSettled(state); frame += 1) {
state = advanceTextReveal(state, 16);
}
expect(isTextRevealSettled(state)).toBe(true);
expect(visibleRevealedText(state)).toBe(full);
});
});
describe("trailing fragments from arrival boundaries", () => {
it("holds back half a flag while more text is coming", () => {
// The daemon's coalescing window can end a delta mid-cluster, so a caught-up
// reveal can be handed a single regional indicator.
const halfFlag = "flags: \u{1F1EC}";
const state = beginTextReveal(halfFlag);
expect(visibleRevealedText(state, { streaming: true })).toBe("flags: ");
// Once the turn ends nothing more is coming, so paint what there is.
expect(visibleRevealedText(state, { streaming: false })).toBe(halfFlag);
});
it("paints the flag once its second half arrives", () => {
const whole = "flags: \u{1F1EC}\u{1F1E7}";
expect(visibleRevealedText(beginTextReveal(whole), { streaming: true })).toBe(whole);
});
it("keeps an even run of regional indicators intact", () => {
const twoFlags = "\u{1F1EC}\u{1F1E7}\u{1F1EB}\u{1F1F7}";
expect(visibleRevealedText(beginTextReveal(twoFlags), { streaming: true })).toBe(twoFlags);
});
it("holds back a dangling joiner", () => {
expect(
visibleRevealedText(beginTextReveal("family: \u{1F468}\u200D"), { streaming: true }),
).toBe("family: \u{1F468}");
});
it("holds back a dangling high surrogate", () => {
const loneHigh = `broken: ${String.fromCharCode(0xd83d)}`;
expect(visibleRevealedText(beginTextReveal(loneHigh), { streaming: true })).toBe("broken: ");
});
it("leaves ordinary trailing text alone", () => {
const prose = "just some prose";
expect(visibleRevealedText(beginTextReveal(prose), { streaming: true })).toBe(prose);
});
});
@@ -0,0 +1,240 @@
/**
* Paced reveal for streaming assistant text.
*
* Deltas arrive lumpy: the daemon coalesces one message per 60ms window, carrying
* however many characters the model happened to produce in that window. Painting
* each delta as it lands makes the size of those lumps visible, which is what
* reads as jagged. So arrival changes the *target* and the reveal rate is derived
* from the backlog instead — a burst makes the text catch up faster, it doesn't
* make it jump.
*
* The store keeps the full text. Only the rendered slice is paced, so copy,
* selection, the chat outline, and scroll geometry all stay consistent with what
* is on screen.
*
* Everything here is pure. The only thing the React hook adds is a frame clock,
* which keeps the policy testable without a renderer.
*/
// Backlog is drained over this horizon. Shorter feels more like the raw arrival
// pattern; longer adds lag the user can notice at the end of a turn.
export const TEXT_REVEAL_HORIZON_MS = 150;
/** Reveal at most once per 60Hz frame, even on high-refresh displays. */
export const TEXT_REVEAL_FRAME_INTERVAL_MS = 1000 / 60;
// A frame's elapsed time is clamped to this before it is used, so a long stall
// (backgrounded tab, blocked main thread) doesn't produce a wild step from one
// enormous delta.
const MAX_ELAPSED_MS = 250;
/**
* Characters to reveal on this frame. Proportional to the backlog, so the reveal
* accelerates when the model runs ahead and settles when it doesn't, with a
* one-character floor so the tail always finishes.
*/
export function computeRevealStep(input: {
backlog: number;
elapsedMs: number;
horizonMs?: number;
}): number {
const { backlog } = input;
if (backlog <= 0) {
return 0;
}
const horizonMs = input.horizonMs ?? TEXT_REVEAL_HORIZON_MS;
if (horizonMs <= 0) {
return backlog;
}
const elapsedMs = Math.min(Math.max(input.elapsedMs, 0), MAX_ELAPSED_MS);
if (elapsedMs <= 0) {
return 0;
}
if (elapsedMs >= horizonMs) {
return backlog;
}
const step = Math.ceil((backlog * elapsedMs) / horizonMs);
return Math.min(backlog, Math.max(1, step));
}
const ZERO_WIDTH_JOINER = 0x200d;
const graphemeSegmenter =
typeof Intl.Segmenter === "function"
? new Intl.Segmenter(undefined, { granularity: "grapheme" })
: null;
/**
* Pacing needs a conformant grapheme segmenter. Older runtimes paint each
* arrival whole instead of risking a transient broken glyph.
*/
export function isTextRevealPacingSupported(): boolean {
return graphemeSegmenter !== null;
}
/**
* Pull a cut index back to somewhere it is safe to slice.
*
* A raw `slice` at an arbitrary index can split a grapheme cluster, which makes
* one visible glyph briefly render as separate parts while the text is streaming.
*
* This only moves the *rendered* boundary. The reveal counter stays monotonic, so
* a long cluster can never stall the reveal — the next frame steps past it.
*/
export function clampToSafeRevealBoundary(text: string, index: number): number {
if (index <= 0) {
return 0;
}
if (index >= text.length) {
return text.length;
}
const segment = graphemeSegmenter?.segment(text).containing(index);
return segment?.index ?? 0;
}
export interface TextRevealFrame {
elapsedMs: number;
frameAtMs: number;
}
/**
* Keep reveal commits at 60Hz while carrying timing remainder forward so a
* high-refresh display does not make the reveal render at hardware frame rate.
*/
export function nextTextRevealFrame(
previousFrameAtMs: number | null,
timestampMs: number,
): TextRevealFrame | null {
const elapsedMs =
previousFrameAtMs === null ? TEXT_REVEAL_FRAME_INTERVAL_MS : timestampMs - previousFrameAtMs;
if (elapsedMs < TEXT_REVEAL_FRAME_INTERVAL_MS) {
return null;
}
return {
elapsedMs,
frameAtMs: timestampMs - (elapsedMs % TEXT_REVEAL_FRAME_INTERVAL_MS),
};
}
export interface TextRevealState {
/** The full text as the store knows it. */
readonly target: string;
/** How much of it has been released. Monotonic within a message. */
readonly revealed: number;
}
/**
* First sight of a text is revealed whole. Only growth is paced, which is what
* makes history hydration, timeline replay, a virtualized row remounting on
* scroll, and an already-finished message all render complete on first paint
* without a special case for each.
*/
export function beginTextReveal(text: string): TextRevealState {
return { target: text, revealed: text.length };
}
/** Point the reveal at newly arrived text without changing how much is shown. */
export function retargetTextReveal(state: TextRevealState, text: string): TextRevealState {
if (state.target === text) {
return state;
}
// A shorter string means this slot is showing a different message than the one
// the reveal position belongs to.
return { target: text, revealed: Math.min(state.revealed, text.length) };
}
/** Release one frame's worth of characters. */
export function advanceTextReveal(
state: TextRevealState,
elapsedMs: number,
horizonMs?: number,
): TextRevealState {
const step = computeRevealStep({
backlog: state.target.length - state.revealed,
elapsedMs,
...(horizonMs !== undefined ? { horizonMs } : {}),
});
if (step <= 0) {
return state;
}
return { target: state.target, revealed: Math.min(state.target.length, state.revealed + step) };
}
/** Release everything, for when the turn ends and nothing may be held back. */
export function completeTextReveal(state: TextRevealState): TextRevealState {
if (state.revealed >= state.target.length) {
return state;
}
return { target: state.target, revealed: state.target.length };
}
export function isTextRevealSettled(state: TextRevealState): boolean {
return state.revealed >= state.target.length;
}
/**
* Hold back a trailing fragment that cannot stand on its own.
*
* The reveal boundary is not the only place a cluster gets cut. The daemon's
* coalescing window ends a delta wherever the window closes, so a caught-up
* reveal can be handed half a flag and paint it — measured on a mixed-script
* stream as frames ending in a single regional indicator. Only fragments that
* are certainly waiting on more input are withheld: a dangling high surrogate, a
* trailing joiner, or an odd regional indicator.
*/
function trimIncompleteTrailingCluster(text: string): string {
if (text.length === 0) {
return text;
}
const lastUnit = text.charCodeAt(text.length - 1);
if (lastUnit >= 0xd800 && lastUnit <= 0xdbff) {
return text.slice(0, -1);
}
if (lastUnit === ZERO_WIDTH_JOINER) {
return text.slice(0, -1);
}
const trailingRegionalIndicators = countTrailingRegionalIndicators(text);
if (trailingRegionalIndicators % 2 === 1) {
return text.slice(0, -2);
}
return text;
}
/** Number of regional indicators at the end of `text`. A flag is a pair. */
function countTrailingRegionalIndicators(text: string): number {
let count = 0;
let cursor = text.length;
while (cursor >= 2) {
const codePoint = text.codePointAt(cursor - 2);
if (codePoint === undefined || codePoint < 0x1f1e6 || codePoint > 0x1f1ff) {
break;
}
count += 1;
cursor -= 2;
}
return count;
}
/**
* What should actually be painted this frame.
*
* `streaming` says whether more text is still expected. While it is, a trailing
* fragment waits for the rest of its cluster; once the turn ends everything
* received is painted, because nothing more is coming.
*/
export function visibleRevealedText(
state: TextRevealState,
options?: { streaming?: boolean },
): string {
if (state.revealed >= state.target.length) {
return options?.streaming ? trimIncompleteTrailingCluster(state.target) : state.target;
}
return state.target.slice(0, clampToSafeRevealBoundary(state.target, state.revealed));
}
+13 -16
View File
@@ -52,7 +52,7 @@ import type {
} from "@getpaseo/protocol/agent-types";
import type { AgentScreenAgent } from "@/hooks/use-agent-screen-state-machine";
import { useSessionStore } from "@/stores/session-store";
import { StreamingWords, useWordStream } from "@/word-stream";
import { useRevealedText } from "@/hooks/use-revealed-text";
import { useFileExplorerActions } from "@/hooks/use-file-explorer-actions";
import { useLoadOlderAgentHistory } from "@/hooks/use-load-older-agent-history";
import { useSettings } from "@/hooks/use-settings";
@@ -1294,7 +1294,7 @@ interface ThoughtSlotProps {
defaultExpanded: boolean;
}
// Reasoning text is paced the same way assistant text is; see @/word-stream.
// Reasoning text is paced the same way assistant text is; see @/hooks/use-revealed-text.
function ThoughtSlot({
itemId,
onInlineDetailsExpandedChangeByItemId,
@@ -1303,21 +1303,18 @@ function ThoughtSlot({
isLastInSequence,
defaultExpanded,
}: ThoughtSlotProps) {
const stream = useWordStream(text, status === "ready" ? "complete" : "streaming");
const revealedText = stream.text;
const revealedText = useRevealedText(text, status === "ready" ? "complete" : "streaming");
return (
<StreamingWords stream={stream}>
<ToolCallSlot
itemId={itemId}
onInlineDetailsExpandedChangeByItemId={onInlineDetailsExpandedChangeByItemId}
toolName="thinking"
args={revealedText}
status={status === "ready" ? "completed" : "executing"}
isLastInSequence={isLastInSequence}
defaultExpanded={defaultExpanded}
forceInline={defaultExpanded}
/>
</StreamingWords>
<ToolCallSlot
itemId={itemId}
onInlineDetailsExpandedChangeByItemId={onInlineDetailsExpandedChangeByItemId}
toolName="thinking"
args={revealedText}
status={status === "ready" ? "completed" : "executing"}
isLastInSequence={isLastInSequence}
defaultExpanded={defaultExpanded}
forceInline={defaultExpanded}
/>
);
}
@@ -1,7 +1,13 @@
import { useMemo, type ReactNode } from "react";
import { View, type StyleProp, type TextProps, type TextStyle, type ViewStyle } from "react-native";
import {
Text,
View,
type StyleProp,
type TextProps,
type TextStyle,
type ViewStyle,
} from "react-native";
import type { MarkdownCopyInlineTag } from "@/assistant-selection-copy/markup";
import { WordFadeText } from "@/word-stream/text";
interface MarkdownTextSpanProps {
style?: StyleProp<TextStyle>;
@@ -23,9 +29,9 @@ export function MarkdownTextSpan({
accessibilityRole,
}: MarkdownTextSpanProps) {
return (
<WordFadeText selectable style={style} onPress={onPress} accessibilityRole={accessibilityRole}>
<Text selectable style={style} onPress={onPress} accessibilityRole={accessibilityRole}>
{children}
</WordFadeText>
</Text>
);
}
@@ -7,7 +7,7 @@ import {
type TextStyle,
type ViewStyle,
} from "react-native";
import { WordFadeText } from "@/word-stream/text";
import { UITextView } from "react-native-uitextview";
import { resolvePlainMarkdownTextStyle } from "@/components/markdown-text-style";
import {
iosMarkdownTextIsSelectable,
@@ -60,14 +60,15 @@ export function MarkdownTextSpan({
}
return (
<WordFadeText
<UITextView
uiTextView
selectable
style={plainStyle}
onPress={onPress}
accessibilityRole={accessibilityRole}
>
{children}
</WordFadeText>
</UITextView>
);
}
@@ -105,8 +106,8 @@ export function MarkdownParagraphView({
}
return (
<WordFadeText selectable style={textStyle}>
<UITextView uiTextView selectable style={textStyle}>
{children}
</WordFadeText>
</UITextView>
);
}
@@ -1,6 +1,12 @@
import { useMemo, type ReactNode } from "react";
import { View, type StyleProp, type TextProps, type TextStyle, type ViewStyle } from "react-native";
import { WordFadeText } from "@/word-stream/text";
import {
Text,
View,
type StyleProp,
type TextProps,
type TextStyle,
type ViewStyle,
} from "react-native";
import { CODE_SURFACE_DATASET } from "@/styles/code-surface";
import { markdownCopyDataSet, type MarkdownCopyInlineTag } from "@/assistant-selection-copy/markup";
@@ -40,14 +46,9 @@ export function MarkdownTextSpan({
}, [copyTag, monoSurface]);
return (
<WordFadeText
dataSet={dataSet}
style={style}
onPress={onPress}
accessibilityRole={accessibilityRole}
>
<Text dataSet={dataSet} style={style} onPress={onPress} accessibilityRole={accessibilityRole}>
{children}
</WordFadeText>
</Text>
);
}
@@ -1,4 +1,3 @@
import { useWordFadeRules } from "@/word-stream/markdown";
import React, {
useCallback,
useEffect,
@@ -81,7 +80,6 @@ export interface MarkdownRendererProps {
allowedImageHandlers?: readonly string[];
topLevelMaxExceededItem?: ReactNode;
enableHtmlish?: boolean;
sourceOffset?: number;
}
export function MarkdownRenderer({
@@ -93,7 +91,6 @@ export function MarkdownRenderer({
allowedImageHandlers,
topLevelMaxExceededItem,
enableHtmlish = true,
sourceOffset = 0,
}: MarkdownRendererProps) {
const markdownRules = useMemo(() => rules ?? createSharedMarkdownRules(), [rules]);
const parts = useMemo(
@@ -103,7 +100,6 @@ export function MarkdownRenderer({
const rendererProps = useMemo(
() => ({
compact,
sourceOffset,
rules: markdownRules,
markdownit,
onLinkPress,
@@ -113,7 +109,6 @@ export function MarkdownRenderer({
[
allowedImageHandlers,
compact,
sourceOffset,
markdownRules,
markdownit,
onLinkPress,
@@ -179,7 +174,6 @@ function MarkdownPart({
function MarkdownFragment({
text,
sourceOffset = 0,
compact,
rules,
markdownit,
@@ -187,12 +181,11 @@ function MarkdownFragment({
allowedImageHandlers,
topLevelMaxExceededItem,
}: MarkdownRendererProps & { rules: RenderRules }) {
const fadeRules = useWordFadeRules(rules, sourceOffset);
const uniProps = compact ? compactMarkdownStyleMapping : markdownStyleMapping;
return (
<ThemedMarkdown
uniProps={uniProps}
rules={fadeRules}
rules={rules}
markdownit={markdownit}
onLinkPress={onLinkPress}
allowedImageHandlers={allowedImageHandlers}
+35 -47
View File
@@ -69,7 +69,7 @@ import { HighlightedCodeBlock } from "@/components/highlighted-code-block";
import { MarkdownFenceBlock } from "@/components/markdown/fence";
import type { MarkdownPhase } from "@/components/markdown/fence/types";
import { splitMarkdownBlocks } from "@/utils/split-markdown-blocks";
import { StreamingWords, useWordStream } from "@/word-stream";
import { useRevealedText } from "@/hooks/use-revealed-text";
import { colorMarkdownLinkChildren } from "@/components/markdown/link-children";
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
import { formatDuration, formatMessageTimestamp } from "@/utils/time";
@@ -1391,7 +1391,6 @@ function AssistantMessageBlockContainer({
interface MemoizedMarkdownBlockProps {
text: string;
sourceOffset: number;
rules: RenderRules;
parser: MarkdownIt;
onLinkPress: (url: string) => boolean;
@@ -1399,7 +1398,6 @@ interface MemoizedMarkdownBlockProps {
const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
text,
sourceOffset,
rules,
parser,
onLinkPress,
@@ -1407,7 +1405,6 @@ const MemoizedMarkdownBlock = React.memo(function MemoizedMarkdownBlock({
return (
<MarkdownRenderer
text={text}
sourceOffset={sourceOffset}
enableHtmlish={false}
rules={rules}
markdownit={parser}
@@ -1517,10 +1514,8 @@ export const AssistantMessage = memo(function AssistantMessage({
renderFullContent ? { text: message, capped: false } : capAssistantMessageForRender(message),
[message, renderFullContent],
);
// Paint a paced prefix while the turn is streaming so text arrives at a steady
// rate instead of in whatever lumps the daemon's coalescing window produced.
const stream = useWordStream(renderedMessage.text, phase);
const revealedMessage = renderFullContent ? renderedMessage.text : stream.text;
const revealedText = useRevealedText(renderedMessage.text, phase);
const revealedMessage = renderFullContent ? renderedMessage.text : revealedText;
const fullMessageByteLength = useMemo(
() => (renderedMessage.capped && phase === "complete" ? getUtf8ByteLength(message) : null),
[message, phase, renderedMessage.capped],
@@ -1964,14 +1959,10 @@ export const AssistantMessage = memo(function AssistantMessage({
}, [client, fileLinkActions, markdownParser, occurrenceKey, phase, serverId, workspaceRoot]);
const blocks = useMemo(() => splitMarkdownBlocks(revealedMessage), [revealedMessage]);
const keyedBlocks = useMemo(() => {
let cursor = 0;
return blocks.map((block) => {
const sourceOffset = revealedMessage.indexOf(block, cursor);
cursor = sourceOffset + block.length;
return { key: `block:${sourceOffset}`, block, sourceOffset };
});
}, [blocks, revealedMessage]);
const keyedBlocks = useMemo(
() => blocks.map((block, index) => ({ key: `block:${index}`, block })),
[blocks],
);
const assistantContainerStyle = useMemo(
() => [
@@ -1996,37 +1987,34 @@ export const AssistantMessage = memo(function AssistantMessage({
);
return (
<StreamingWords stream={stream}>
<View testID="assistant-message" dataSet={revealDataSet} style={assistantContainerStyle}>
{keyedBlocks.map(({ key, block, sourceOffset }, index) => (
<AssistantMessageBlockContainer
key={key}
block={block}
marginBottom={index < keyedBlocks.length - 1 ? 12 : 0}
>
<MemoizedMarkdownBlock
text={block}
sourceOffset={sourceOffset}
rules={markdownRules}
parser={
phase === "streaming" && index === keyedBlocks.length - 1
? streamingMarkdownParser
: markdownParser
}
onLinkPress={handleMarkdownLinkPress}
/>
</AssistantMessageBlockContainer>
))}
{fullMessageByteLength !== null ? (
<Text
testID="assistant-message-capped-notice"
style={assistantMessageStylesheet.cappedNotice}
>
{t("agentStream.messageCapped", { bytes: fullMessageByteLength })}
</Text>
) : null}
</View>
</StreamingWords>
<View testID="assistant-message" dataSet={revealDataSet} style={assistantContainerStyle}>
{keyedBlocks.map(({ key, block }, index) => (
<AssistantMessageBlockContainer
key={key}
block={block}
marginBottom={index < keyedBlocks.length - 1 ? 12 : 0}
>
<MemoizedMarkdownBlock
text={block}
rules={markdownRules}
parser={
phase === "streaming" && index === keyedBlocks.length - 1
? streamingMarkdownParser
: markdownParser
}
onLinkPress={handleMarkdownLinkPress}
/>
</AssistantMessageBlockContainer>
))}
{fullMessageByteLength !== null ? (
<Text
testID="assistant-message-capped-notice"
style={assistantMessageStylesheet.cappedNotice}
>
{t("agentStream.messageCapped", { bytes: fullMessageByteLength })}
</Text>
) : null}
</View>
);
});
+87 -1
View File
@@ -1 +1,87 @@
export { useWordPacedText as useRevealedText } from "@/word-stream";
import { useEffect, useRef, useState } from "react";
import {
advanceTextReveal,
beginTextReveal,
completeTextReveal,
isTextRevealPacingSupported,
isTextRevealSettled,
nextTextRevealFrame,
retargetTextReveal,
type TextRevealState,
visibleRevealedText,
} from "@/agent-stream/text-reveal";
import type { MarkdownPhase } from "@/components/markdown/fence/types";
/**
* Binds the paced reveal in @/agent-stream/text-reveal to a frame clock.
*
* All of the policy — what is revealed when, and where it is safe to cut — lives
* in that module and is tested there. This hook only owns the requestAnimationFrame
* wiring, so the rendered behavior is covered end to end by
* `packages/app/e2e/browser/agent-stream-smoothness.spec.ts`.
*/
export function useRevealedText(text: string, phase: MarkdownPhase): string {
const pacingSupported = isTextRevealPacingSupported();
const stateRef = useRef<TextRevealState>(beginTextReveal(text));
const [, forceRender] = useState(0);
const frameRef = useRef<number | null>(null);
const lastFrameAtRef = useRef<number | null>(null);
stateRef.current = retargetTextReveal(stateRef.current, text);
useEffect(() => {
const settle = () => {
lastFrameAtRef.current = null;
const next = completeTextReveal(stateRef.current);
if (next !== stateRef.current) {
stateRef.current = next;
forceRender((tick) => tick + 1);
}
};
if (!pacingSupported || phase !== "streaming") {
settle();
return;
}
if (isTextRevealSettled(stateRef.current)) {
lastFrameAtRef.current = null;
return;
}
if (typeof requestAnimationFrame !== "function") {
settle();
return;
}
const tick = (timestamp: number) => {
frameRef.current = null;
const frame = nextTextRevealFrame(lastFrameAtRef.current, timestamp);
if (!frame) {
frameRef.current = requestAnimationFrame(tick);
return;
}
lastFrameAtRef.current = frame.frameAtMs;
const next = advanceTextReveal(stateRef.current, frame.elapsedMs);
if (next !== stateRef.current) {
stateRef.current = next;
forceRender((count) => count + 1);
}
if (!isTextRevealSettled(stateRef.current)) {
frameRef.current = requestAnimationFrame(tick);
}
};
frameRef.current = requestAnimationFrame(tick);
return () => {
if (frameRef.current !== null) {
cancelAnimationFrame(frameRef.current);
frameRef.current = null;
}
};
}, [text, pacingSupported, phase]);
return pacingSupported
? visibleRevealedText(stateRef.current, { streaming: phase === "streaming" })
: text;
}
@@ -1,4 +1,3 @@
import { trackMarkdownSource } from "@/word-stream";
import type MarkdownIt from "markdown-it";
import { createMarkdownParser } from "@/utils/markdown-parser";
import { enableStreamingMarkdown } from "@/utils/streaming-markdown";
@@ -16,6 +15,5 @@ export function createAssistantMarkdownParser({ streaming = false } = {}): Markd
enableStreamingMarkdown(parser);
}
trackMarkdownSource(parser);
return parser;
}
-92
View File
@@ -1,92 +0,0 @@
import {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import type { MarkdownPhase } from "@/components/markdown/fence/types";
import { Reveal } from "./internal/reveal";
import type { SourceText } from "./internal/markdown-source";
import { WordStream } from "./internal/model";
/**
* The paced prefix to paint, how long its latest word owns the reveal front,
* shared by every renderer.
*/
export interface PacedText {
text: string;
spanMs: number;
}
const StreamingWordsContext = createContext<Reveal>(Reveal.begin(""));
const SurfaceContext = createContext<SourceText | null>(null);
export function StreamingWords({ stream, children }: { stream: PacedText; children: ReactNode }) {
const [reveal, setReveal] = useState(() => Reveal.begin(stream.text));
const current = reveal.receive(stream.text, Date.now(), stream.spanMs);
if (current !== reveal) setReveal(current);
return <StreamingWordsContext value={current}>{children}</StreamingWordsContext>;
}
export function WordFadeScope({ source, children }: { source: SourceText; children: ReactNode }) {
return <SurfaceContext value={source}>{children}</SurfaceContext>;
}
export function useWordFadeSurface() {
const reveal = useContext(StreamingWordsContext);
const source = useContext(SurfaceContext);
const plain = useMemo(() => source && { text: source.text, ranges: [] }, [source]);
return useMemo(() => {
const text = source?.text ?? reveal.text;
const offsets = source?.offsets ?? Array.from({ length: text.length }, (_, i) => i);
const ranges = reveal.surface(text, offsets, Date.now());
return ranges.length === 0 && plain ? plain : { text, ranges };
}, [reveal, source, plain]);
}
export function useWordStream(text: string, phase: MarkdownPhase): PacedText {
const [model] = useState(() => new WordStream(text));
const [visible, setVisible] = useState<PacedText>({
text,
spanMs: model.spanMs,
});
const lastFrame = useRef<number | null>(null);
useEffect(() => {
model.receive(text, phase === "streaming");
const publish = () => {
setVisible((current) =>
current.text === model.text && current.spanMs === model.spanMs
? current
: { text: model.text, spanMs: model.spanMs },
);
};
publish();
if (!model.pending) {
lastFrame.current = null;
return;
}
let frame: number;
const tick = (now: number) => {
const elapsed = lastFrame.current === null ? 16 : now - lastFrame.current;
lastFrame.current = now;
model.advance(elapsed);
publish();
if (model.pending) frame = requestAnimationFrame(tick);
else lastFrame.current = null;
};
frame = requestAnimationFrame(tick);
return () => cancelAnimationFrame(frame);
}, [model, text, phase]);
return visible;
}
export function useWordPacedText(text: string, phase: MarkdownPhase): string {
return useWordStream(text, phase).text;
}
export { trackMarkdownSource } from "./internal/markdown-source";
@@ -1,21 +0,0 @@
import { describe, expect, it } from "vitest";
import { hostTextStyle } from "./host-style";
describe("native fade host layout", () => {
it("keeps paragraph, heading, list and cell geometry on the outer box", () => {
const style = {
marginTop: 8,
width: "100%" as const,
flexShrink: 1,
alignSelf: "flex-start" as const,
paddingHorizontal: 4,
fontSize: 20,
lineHeight: 26,
backgroundColor: "gray",
};
expect(hostTextStyle(style)).toEqual({
host: { marginTop: 8, width: "100%", flexShrink: 1, alignSelf: "flex-start" },
text: { paddingHorizontal: 4, fontSize: 20, lineHeight: 26, backgroundColor: "gray" },
});
});
});
@@ -1,71 +0,0 @@
import type { TextStyle, ViewStyle } from "react-native";
/** Move parent-facing geometry to the host; keep the text's box and typography together. */
export function hostTextStyle(style: TextStyle) {
const {
alignSelf,
aspectRatio,
bottom,
display,
end,
flex,
flexBasis,
flexGrow,
flexShrink,
height,
left,
margin,
marginBottom,
marginEnd,
marginHorizontal,
marginLeft,
marginRight,
marginStart,
marginTop,
marginVertical,
maxHeight,
maxWidth,
minHeight,
minWidth,
position,
right,
start,
top,
width,
zIndex,
...text
} = style;
const host: ViewStyle = {
alignSelf,
aspectRatio,
bottom,
display,
end,
flex,
flexBasis,
flexGrow,
flexShrink,
height,
left,
margin,
marginBottom,
marginEnd,
marginHorizontal,
marginLeft,
marginRight,
marginStart,
marginTop,
marginVertical,
maxHeight,
maxWidth,
minHeight,
minWidth,
position,
right,
start,
top,
width,
zIndex,
};
return { host, text };
}
@@ -1,37 +0,0 @@
import { expect, it } from "vitest";
import { createAssistantMarkdownParser } from "@/utils/assistant-markdown-parser";
const corpus = [
"first words\r\n\r\nsecond words ",
"visit https://example.com/a%20b and www.example.com then words ",
"![alt words](image.png) following words ",
"same same **same same** same ",
"**bold words** and _italic words_ and ~~strike words~~ ",
"[same words](https://example.com/same) same words ",
"`code words` and `` code ` words `` then ",
"&amp; &#x1f600; café 👨‍👩‍👧‍👦 \\*escaped\\* words ",
"- first words\n\n- same words\n - same words\n\nlast words ",
"| same | same |\n| --- | --- |\n| same | a\\|b |\n",
"heading words\n=====\n\nparagraph words ",
"> same words\n> same words\n\nnext words ",
"before words \nafter words\n\nfence:\n```ts\nlet x = 1;\n```\n\nafter words ",
];
it.each(corpus)("keeps rendered characters at their source positions: %s", (source) => {
const parser = createAssistantMarkdownParser({ streaming: true });
for (let end = 1; end <= source.length; end++) {
const prefix = source.slice(0, end);
for (const token of parser.parse(prefix, {}).flatMap((block) => block.children ?? [])) {
if (!["text", "code_inline"].includes(token.type) || !token.content) continue;
const offsets: number[] = token.meta.wordSource;
expect(offsets.length, `${prefix}: ${token.content}`).toBe(token.content.length);
expect(
offsets.every((offset) => Number.isInteger(offset) && offset >= 0 && offset < end),
`${prefix}: ${token.content} ${offsets}`,
).toBe(true);
for (let i = 0; i < token.content.length; i++) {
if (/\w/u.test(token.content[i]!) && source[offsets[i]!] !== "&")
expect(source[offsets[i]!], `${prefix}: ${token.content}`).toBe(token.content[i]);
}
}
}
});
@@ -1,179 +0,0 @@
import type MarkdownIt from "markdown-it";
import type Token from "markdown-it/lib/token.mjs";
export interface SourceText {
text: string;
offsets: number[];
}
const positions = new WeakMap<Token, number[]>();
/** Preserve the parser's source positions before inline syntax is discarded. */
export function trackMarkdownSource(parser: MarkdownIt): void {
const originals = new WeakMap<object, number[]>();
parser.core.ruler.before("normalize", "word_source_original", (state) => {
const offsets: number[] = [];
for (let i = 0; i < state.src.length; i++) {
offsets.push(i);
if (state.src[i] === "\r" && state.src[i + 1] === "\n") i++;
}
originals.set(state, offsets);
});
const Base = parser.inline.State;
parser.inline.State = class extends Base {
constructor(...args: ConstructorParameters<typeof Base>) {
super(...args);
let pending = this.pending;
let pendingPosition = this.pos;
let offsets: number[] = [];
Object.defineProperty(this, "pending", {
get: () => {
pendingPosition = this.pos;
return pending;
},
set: (next: string) => {
if (next.startsWith(pending)) {
const addition = next.slice(pending.length);
let start = pendingPosition;
if (this.src[start] === "\\" && addition !== "\\") start++;
if (!this.src.startsWith(addition, start) && this.src.startsWith(addition, start - 1))
start--;
const literal = this.src.startsWith(addition, start);
offsets.push(
...Array.from({ length: addition.length }, (_, i) => start + (literal ? i : 0)),
);
} else offsets = offsets.slice(0, next.length);
pending = next;
},
});
const pushPending = this.pushPending.bind(this);
this.pushPending = () => {
const saved = offsets;
const token = pushPending();
positions.set(token, saved);
offsets = [];
return token;
};
}
override push(...args: Parameters<InstanceType<typeof Base>["push"]>): Token {
const token = super.push(...args);
const start = this.pos;
let content = token.content;
Object.defineProperty(token, "content", {
enumerable: true,
get: () => content,
set: (next: string) => {
if (!positions.has(token) && next) {
let from = start;
if (token.type === "code_inline") {
from += token.markup.length;
if (this.src[from] === " " && this.src[from + 1] !== " " && next[0] !== " ") from++;
}
positions.set(
token,
Array.from({ length: next.length }, (_, i) => from + i),
);
}
content = next;
},
});
return token;
}
};
// markdown-it merges text tokens by keeping the right-hand token. Preserve
// the concatenated mapping through that existing pass, without changing it.
parser.inline.ruler2.before("text_collapse", "word_source_join", (state) => {
for (let i = 0; i + 1 < state.tokens.length; i++) {
const left = state.tokens[i]!;
const right = state.tokens[i + 1]!;
if (left.type === "text" && right.type === "text") {
positions.set(right, [
...(positions.get(left) ?? []).slice(0, left.content.length),
...(positions.get(right) ?? []).slice(0, right.content.length),
]);
}
}
return true;
});
const mappedBlocks = new WeakMap<Token, SourceText>();
parser.core.ruler.after("inline", "word_source", (state) => {
let cursor = 0;
const lineStarts = [0];
for (let i = 0; i < state.src.length; i++) if (state.src[i] === "\n") lineStarts.push(i + 1);
for (const block of state.tokens) {
if (block.map) cursor = Math.max(cursor, lineStarts[block.map[0]]!);
if (block.type !== "inline") {
if (block.map && block.nesting === 0) cursor = lineStarts[block.map[1]] ?? state.src.length;
continue;
}
if (block.map) cursor = Math.max(cursor, lineStarts[block.map[0]]!);
const sourceOffsets: number[] = [];
// Block parsing removes indentation, quote/list markers and table escapes.
// Inline contents occur in source order, including repeated table cells.
for (const char of block.content.split("")) {
const next = state.src.indexOf(char, cursor);
if (next < 0) throw new Error("Markdown inline content lost its source position");
sourceOffsets.push(next);
cursor = next + 1;
}
const rendered: SourceText = { text: "", offsets: [] };
for (const token of block.children ?? []) {
const local = positions.get(token) ?? [];
const offsets = local
.slice(0, token.content.length)
.map((offset) => originals.get(state)![sourceOffsets[offset]!]!);
token.meta = { ...token.meta, wordSource: offsets };
if (
token.type === "text" ||
token.type === "streaming_link_text" ||
token.type === "code_inline"
) {
rendered.text += token.content;
rendered.offsets.push(...offsets);
}
}
mappedBlocks.set(block, rendered);
}
});
// Linkify replaces text tokens. Transfer the same character positions to its
// split output; the link wrapper itself never owns a fade.
parser.core.ruler.after("linkify", "word_source_links", (state) => {
for (const block of state.tokens) {
const mapped = mappedBlocks.get(block);
if (!mapped) continue;
let cursor = 0;
for (const token of block.children ?? []) {
if (!["text", "streaming_link_text", "code_inline"].includes(token.type)) continue;
const mappedToken = mapLinkifiedText(mapped, token.content, cursor);
const offsets = mappedToken.offsets;
cursor = mappedToken.cursor;
token.meta = { ...token.meta, wordSource: offsets };
}
}
});
}
function mapLinkifiedText(mapped: SourceText, content: string, cursor: number) {
const offsets: number[] = [];
for (let i = 0; i < content.length; i++) {
const encoded = mapped.text.slice(cursor).match(/^(?:%[0-9a-f]{2})+/i)?.[0];
if (encoded && content[i] !== "%") {
let decoded: string;
try {
decoded = decodeURIComponent(encoded);
} catch {
decoded = encoded;
}
if (content.startsWith(decoded, i)) {
offsets.push(...Array.from({ length: decoded.length }, () => mapped.offsets[cursor]!));
cursor += encoded.length;
i += decoded.length - 1;
continue;
}
}
offsets.push(mapped.offsets[cursor++]!);
}
return { offsets, cursor };
}
@@ -1,103 +0,0 @@
import { describe, expect, it } from "vitest";
import { WordStream } from "./model";
describe("word streaming", () => {
it("buffers a network fragment until the word is complete", () => {
const stream = new WordStream("");
stream.receive("hel", true);
expect(stream.advance(100)).toBe("");
stream.receive("hello world ", true);
expect(stream.advance(16)).toBe("hello ");
expect(stream.advance(100)).toBe("hello world ");
});
it("paces a burst by words instead of releasing the network chunk", () => {
const stream = new WordStream("");
stream.receive("one two three four five six ", true);
expect(stream.advance(16)).toBe("one ");
expect(stream.advance(16)).toBe("one ");
expect(stream.advance(17)).toBe("one two ");
expect(stream.advance(40)).toBe("one two three ");
});
it("does not accumulate idle time as credit for the next burst", () => {
const stream = new WordStream("");
stream.advance(5000);
stream.receive("one two three ", true);
expect(stream.advance(16)).toBe("one ");
});
it("preserves history on first paint and drains the final word on completion", () => {
const stream = new WordStream("history ");
expect(stream.text).toBe("history ");
stream.receive("history 👨‍👩‍👧‍👦 café", true);
expect(stream.advance(16)).toBe("history 👨‍👩‍👧‍👦 ");
stream.receive("history 👨‍👩‍👧‍👦 café", false);
expect(stream.advance(100)).toBe("history 👨‍👩‍👧‍👦 café");
expect(stream.pending).toBe(false);
});
it("preserves whitespace, Markdown, and Unicode exactly", () => {
const text = "**café**\n\n- 🇺🇸\t[link](https://paseo.sh) العربية ";
const stream = new WordStream("");
for (let end = 1; end <= text.length; end++) {
stream.receive(text.slice(0, end), true);
const visible = stream.advance(16);
expect(text.startsWith(visible)).toBe(true);
expect(visible === "" || /\s$/u.test(visible)).toBe(true);
}
stream.receive(text, false);
stream.advance(250);
expect(stream.text).toBe(text);
});
it("replaces edited text without replaying the old buffer", () => {
const stream = new WordStream("old text");
stream.receive("new", true);
expect(stream.text).toBe("new");
expect(stream.pending).toBe(false);
});
it("keeps a large burst paced until every word is visible", () => {
const text = "word ".repeat(200);
const stream = new WordStream("");
stream.receive(text, true);
let previous = "";
for (let frame = 0; frame < 800; frame++) {
const visible = stream.advance(1000 / 60);
expect(visible.length - previous.length).toBeLessThanOrEqual(5);
previous = visible;
}
expect(stream.text).toBe(text);
expect(stream.pending).toBe(false);
});
it("paces queued words even when the provider finishes immediately after its last chunk", () => {
const stream = new WordStream("");
stream.receive("one two three four", false);
expect(stream.text).toBe("");
expect(stream.advance(16)).toBe("one ");
expect(stream.advance(250)).toBe("one two ");
expect(stream.advance(250)).toBe("one two three ");
expect(stream.advance(250)).toBe("one two three four");
expect(stream.pending).toBe(false);
});
});
it("reports how long each released word owns the front", () => {
const stream = new WordStream("");
stream.receive("one two three four five six ", true);
stream.advance(16);
// Five words remain: the front moves faster, but never faster than a frame.
expect(stream.spanMs).toBe(33);
stream.receive("one two three four five six ", false);
while (stream.pending) stream.advance(250);
expect(stream.spanMs).toBe(60);
});
it("never turns a delayed frame into a simultaneous word burst", () => {
const stream = new WordStream("");
stream.receive("one two three four five six seven eight nine ten ", true);
expect(stream.advance(250)).toBe("one ");
expect(stream.advance(250)).toBe("one two ");
});
@@ -1,78 +0,0 @@
/** Owns the received text and the word boundary the reader has reached. */
export class WordStream {
private target: string;
private visibleEnd: number;
private scanFrom: number;
private readyEnds: number[] = [];
private nextWord = 0;
private nextInMs = 0;
private interval = 60;
constructor(text: string) {
this.target = text;
this.visibleEnd = text.length;
this.scanFrom = text.length;
}
get text(): string {
return this.target.slice(0, this.visibleEnd);
}
get pending(): boolean {
return this.nextWord < this.readyEnds.length;
}
/**
* How long the most recently released word owns the reveal front before the
* next word follows it. Its characters are spread across this interval so the
* front never restarts at a word boundary.
*/
get spanMs(): number {
return this.interval;
}
receive(text: string, streaming: boolean): void {
if (!text.startsWith(this.target)) {
this.target = text;
this.visibleEnd = text.length;
this.scanFrom = text.length;
this.readyEnds = [];
this.nextWord = 0;
this.nextInMs = 0;
return;
}
this.target = text;
if (!this.pending) {
this.readyEnds = [];
this.nextWord = 0;
this.nextInMs = 0;
}
// Only rescan the unfinished suffix. Network boundaries never finish a word.
const suffix = text.slice(this.scanFrom);
const words = /\S+\s+|\s+/gu;
let end = this.scanFrom;
for (const word of suffix.matchAll(words)) {
end = this.scanFrom + word.index + word[0].length;
this.readyEnds.push(end);
}
this.scanFrom = end;
if (!streaming && end < text.length) {
this.readyEnds.push(text.length);
this.scanFrom = text.length;
}
}
advance(elapsedMs: number): string {
if (!this.pending) return this.text;
this.nextInMs -= Math.min(250, Math.max(0, elapsedMs));
if (this.pending && this.nextInMs <= 0) {
this.visibleEnd = this.readyEnds[this.nextWord++]!;
const backlog = this.readyEnds.length - this.nextWord;
// A late frame is not credit to reveal several words at once.
this.interval = Math.max(33, Math.min(60, 150 / Math.max(1, backlog)));
this.nextInMs = this.interval;
}
if (!this.pending) this.nextInMs = 0;
return this.text;
}
}
@@ -1,79 +0,0 @@
import { expect, it } from "vitest";
import { Reveal } from "./reveal";
it("keeps a word's fade time when its rendered position or surface changes", () => {
// Five whitespace-delimited words share the 60ms interval: 12ms each.
const reveal = Reveal.begin("").receive("| same | same |\n", 1000, 60);
// The two identical words become separate table cells after parsing.
expect(reveal.surface("same", [2, 3, 4, 5], 1080)).toEqual([
{ start: 0, end: 4, startedAt: 1012, spanMs: 12 },
]);
expect(reveal.surface("same", [9, 10, 11, 12], 1080)).toEqual([
{ start: 0, end: 4, startedAt: 1036, spanMs: 12 },
]);
expect(reveal.surface("same", [2, 3, 4, 5], 1230)).toEqual([]);
});
it("keeps previous paragraphs opaque while a new word arrives", () => {
let reveal = Reveal.begin("history ").receive("history same ", 1000, 60);
reveal = reveal.receive("history same same ", 1300, 60);
expect(reveal.surface("same", [8, 9, 10, 11], 1300)).toEqual([]);
expect(reveal.surface("same", [13, 14, 15, 16], 1300)).toEqual([
{ start: 0, end: 4, startedAt: 1300, spanMs: 60 },
]);
});
it("does not turn hydration or replacement into a fade", () => {
const reveal = Reveal.begin("history").receive("replacement", 1000, 60);
expect(
reveal.surface(
"replacement",
Array.from({ length: 11 }, (_, i) => i),
1000,
),
).toEqual([]);
});
it("fades text next to synthetic Markdown line breaks", () => {
const reveal = Reveal.begin("").receive("word \n", 1000, 60);
expect(reveal.surface("word\n", [0, 1, 2, 3, -1], 1050)).toEqual([
{ start: 0, end: 4, startedAt: 1000, spanMs: 60 },
]);
});
it("continues the front across word boundaries instead of restarting it", () => {
// Word interval 60ms: "one" owns 1000–1060, "two" owns 1060–1120.
let reveal = Reveal.begin("").receive("one ", 1000, 60);
reveal = reveal.receive("one two ", 1060, 60);
const [one, two] = reveal.surface("one two", [0, 1, 2, 3, 4, 5, 6], 1070);
expect(one).toEqual({ start: 0, end: 3, startedAt: 1000, spanMs: 60 });
expect(two).toEqual({ start: 4, end: 7, startedAt: 1060, spanMs: 60 });
// The last character of "one" starts at 1040; the first of "two" at 1060.
expect(one!.startedAt + (one!.spanMs * 2) / 3).toBeLessThan(two!.startedAt);
});
it("never lets an early frame start a word before the previous word's run ends", () => {
let reveal = Reveal.begin("").receive("one ", 1000, 60);
reveal = reveal.receive("one two ", 1045, 60);
expect(reveal.ranges.map((range) => range.startedAt)).toEqual([1000, 1060]);
});
it("keeps a run's timeline when inline formatting splits it across surfaces", () => {
const reveal = Reveal.begin("").receive("ab**cd** ", 1000, 80);
// The source word is `ab**cd**` (8 chars, 10ms each); the bold piece starts at
// source offset 4 and is rendered separately from `ab`.
expect(reveal.surface("ab", [0, 1], 1010)).toEqual([
{ start: 0, end: 2, startedAt: 1000, spanMs: 20 },
]);
expect(reveal.surface("cd", [4, 5], 1010)).toEqual([
{ start: 0, end: 2, startedAt: 1040, spanMs: 20 },
]);
});
it("shares one interval between words that arrive together, in order", () => {
const reveal = Reveal.begin("").receive("one two ", 1000, 60);
expect(reveal.ranges).toEqual([
{ start: 0, end: 3, startedAt: 1000, spanMs: 30 },
{ start: 4, end: 7, startedAt: 1030, spanMs: 30 },
]);
});
@@ -1,96 +0,0 @@
/**
* A run of characters that is still fading in.
*
* Character j of n in the run starts fading at `startedAt + spanMs * j / n`
* and takes FADE_DURATION_MS to reach full opacity. Runs are ordered by source
* position and their start times never decrease along that order, so at any
* instant opacity is non-increasing in reading order: one front, not a fade
* per word.
*/
export interface FadeRange {
start: number;
end: number;
startedAt: number;
spanMs: number;
}
export const FADE_DURATION_MS = 150;
function settled(range: FadeRange, now: number): boolean {
return now - (range.startedAt + range.spanMs) >= FADE_DURATION_MS;
}
/** Message-owned reveal history survives changes in Markdown/view structure. */
export class Reveal {
private constructor(
readonly text: string,
readonly ranges: readonly FadeRange[],
) {}
static begin(text: string): Reveal {
return new Reveal(text, []);
}
/**
* `spanMs` is how long the newly arrived text owns the front before the next
* release. When several words arrive together they share it in order.
*/
receive(text: string, now: number, spanMs: number): Reveal {
if (text === this.text) return this;
if (!text.startsWith(this.text)) return Reveal.begin(text);
const ranges = this.ranges.filter((range) => !settled(range, now));
const words = Array.from(text.slice(this.text.length).matchAll(/\S+/gu));
if (words.length === 0) return new Reveal(text, ranges);
// A frame that arrives early must continue the front, never overtake it.
const previous = ranges.at(-1);
const base = Math.max(now, previous ? previous.startedAt + previous.spanMs : now);
const share = spanMs / words.length;
words.forEach((word, index) => {
const start = this.text.length + word.index;
ranges.push({
start,
end: start + word[0].length,
startedAt: base + share * index,
spanMs: share,
});
});
return new Reveal(text, ranges);
}
surface(text: string, offsets: readonly number[], now: number): FadeRange[] {
const result: FadeRange[] = [];
let first = 0;
let last = offsets.length - 1;
while (first <= last && offsets[first]! < 0) first++;
while (last >= first && offsets[last]! < 0) last--;
for (const range of this.ranges) {
if (
settled(range, now) ||
first > last ||
range.end <= offsets[first]! ||
range.start > offsets[last]!
)
continue;
const perSourceChar = range.spanMs / (range.end - range.start);
let start = -1;
for (let i = 0; i <= offsets.length; i++) {
const inside =
i < offsets.length &&
offsets[i]! >= range.start &&
offsets[i]! < range.end &&
!/\s/u.test(text[i]!);
if (inside && start < 0) start = i;
if (!inside && start >= 0) {
// A rendered piece keeps the slice of the word's timeline it came from,
// so a word split by inline formatting still fades as one run.
const from = offsets[start]! - range.start;
const to = offsets[i - 1]! + 1 - range.start;
result.push({
start,
end: i,
startedAt: range.startedAt + perSourceChar * from,
spanMs: perSourceChar * (to - from),
});
start = -1;
}
}
}
return result;
}
}
@@ -1,51 +0,0 @@
import { useMemo } from "react";
import {
renderRules,
type ASTNode,
type RenderRules,
type RenderFunction,
} from "react-native-markdown-display";
import { WordFadeScope } from "..";
import type { SourceText } from "../internal/markdown-source";
function sourceText(node: ASTNode, offset: number): SourceText {
if (node.children.length) {
const children = node.children.map((child) => sourceText(child, offset));
return {
text: children.map((child) => child.text).join(""),
offsets: children.flatMap((child) => child.offsets),
};
}
if (node.type === "softbreak" || node.type === "hardbreak") return { text: "\n", offsets: [-1] };
const meta = Reflect.get(node, "sourceMeta") as { wordSource?: number[] } | undefined;
return { text: node.content, offsets: (meta?.wordSource ?? []).map((value) => value + offset) };
}
/** Keep mapping and animation ownership outside the presentation rules. */
export function useWordFadeRules(rules: RenderRules, sourceOffset: number): RenderRules {
return useMemo(
() =>
Object.fromEntries(
Object.entries({ ...renderRules, ...rules }).map(([name, rule]) => [
name,
rule &&
((...args: Parameters<NonNullable<typeof rule>>) => {
const node = args[0];
return (
<WordFadeScope key={node.key} source={sourceText(node, sourceOffset)}>
{(rule as RenderFunction)(
args[0],
args[1],
args[2],
args[3],
args[4],
...args.slice(5),
)}
</WordFadeScope>
);
}),
]),
),
[rules, sourceOffset],
);
}
@@ -1,11 +0,0 @@
import type { ReactNode } from "react";
import type { ViewProps } from "react-native";
import { requireNativeViewManager } from "expo-modules-core";
import type { FadeRange } from "./internal/reveal";
interface WordFadeHostProps extends ViewProps {
ranges: readonly FadeRange[];
children: ReactNode;
}
export const WordFadeHost = requireNativeViewManager<WordFadeHostProps>("PaseoWordStream");
@@ -1,21 +0,0 @@
import { createContext, useContext } from "react";
import { StyleSheet, Text, type TextProps } from "react-native";
import { useWordFadeSurface } from ".";
import { WordFadeHost } from "./native-host";
import { hostTextStyle } from "./internal/host-style";
const TextAncestor = createContext(false);
export function WordFadeText(props: TextProps) {
const nested = useContext(TextAncestor);
const surface = useWordFadeSurface();
if (nested) return <Text {...props} />;
const style = hostTextStyle(StyleSheet.flatten(props.style) ?? {});
return (
<TextAncestor value={true}>
<WordFadeHost ranges={surface.ranges} style={style.host}>
<Text {...props} style={style.text} />
</WordFadeHost>
</TextAncestor>
);
}
@@ -1,187 +0,0 @@
import { afterAll, afterEach, beforeAll, expect, it } from "vitest";
import React, { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { StreamingWords } from ".";
let WordFadeText: typeof import("./text.web").WordFadeText;
// The browser test runner uses classic JSX for RN source; provide the real runtime.
const previousReact = Reflect.get(globalThis, "React");
beforeAll(async () => {
Reflect.set(globalThis, "React", React);
// Loading the web bundle must also work on runtimes without Intl.Segmenter.
const segmenter = Object.getOwnPropertyDescriptor(Intl, "Segmenter")!;
Reflect.deleteProperty(Intl, "Segmenter");
try {
({ WordFadeText } = await import("./text.web"));
} finally {
Object.defineProperty(Intl, "Segmenter", segmenter);
}
});
afterAll(() => {
if (previousReact === undefined) Reflect.deleteProperty(globalThis, "React");
else Reflect.set(globalThis, "React", previousReact);
});
const textStyle = { fontSize: 24, fontFamily: "Arial" };
const paced = (text: string) => ({ text, spanMs: 60, streaming: true });
let root: Root | undefined;
let host: HTMLDivElement | undefined;
afterEach(async () => {
await act(async () => root?.unmount());
host?.remove();
});
async function reveal(text: string) {
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
const content = (value: string) => (
<StreamingWords stream={paced(value)}>
<WordFadeText style={textStyle}>{value}</WordFadeText>
</StreamingWords>
);
await act(async () => root!.render(content("")));
await act(async () => root!.render(content(text)));
return host;
}
it.each(["abcdefgh", "שלום", "العربية"])("fades %s in visual left-to-right order", async (text) => {
const surface = await reveal(text);
const animations = surface.getAnimations({ subtree: true });
expect(animations.length).toBeGreaterThan(0);
for (const animation of animations) {
animation.pause();
animation.currentTime = 65;
}
const word = surface.querySelector<HTMLElement>("[data-word-fade]")!;
const letters = Array.from(word.children, (child) => ({
left: child.getBoundingClientRect().left,
opacity: Number(getComputedStyle(child).opacity),
})).sort((a, b) => a.left - b.left);
expect(letters[0]!.opacity).toBeGreaterThan(letters.at(-1)!.opacity);
for (let index = 1; index < letters.length; index++) {
expect(letters[index]!.opacity).toBeLessThanOrEqual(letters[index - 1]!.opacity);
}
});
it.each(["office", "العربية", "👨‍👩‍👧‍👦", "café"])("preserves shaping and width for %s", async (text) => {
const surface = await reveal(text);
const word = surface.querySelector<HTMLElement>("[data-word-fade]")!;
const width = word.getBoundingClientRect().width;
const plain = document.createElement("span");
plain.textContent = text;
word.parentElement!.append(plain);
expect(Math.abs(width - plain.getBoundingClientRect().width)).toBeLessThan(0.5);
});
it.each(["👨‍👩‍👧‍👦", "é", "🇺🇸", "가", "क्‍ष"])("fades the grapheme %s as one unit", async (text) => {
const surface = await reveal(text);
expect(surface.textContent).toBe(text);
expect(surface.getAnimations({ subtree: true })).toHaveLength(1);
});
it("does not fade settled text again when its Markdown surface remounts", async () => {
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
const content = (value: string, key: string) => (
<StreamingWords stream={paced(value)}>
<WordFadeText key={key} style={textStyle}>
{value}
</WordFadeText>
</StreamingWords>
);
await act(async () => root!.render(content("", "paragraph")));
await act(async () => root!.render(content("already visible ", "paragraph")));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 250));
});
await act(async () => root!.render(content("already visible next ", "table")));
const fading = Array.from(host.querySelectorAll("[data-word-fade]"), (word) => word.textContent);
expect(fading).toEqual(["next"]);
});
it("does not re-fade repeated words when a paragraph becomes a table", async () => {
const { default: Markdown } = await import("react-native-markdown-display");
const { useWordFadeRules } = await import("./markdown");
const { createAssistantMarkdownParser } = await import("@/utils/assistant-markdown-parser");
const parser = createAssistantMarkdownParser({ streaming: true });
const rules = {
text: (node: { key: string; content: string }) => (
<WordFadeText key={node.key}>{node.content}</WordFadeText>
),
};
function Body({ text }: { text: string }) {
const mapped = useWordFadeRules(rules, 0);
return (
<Markdown markdownit={parser} rules={mapped}>
{text}
</Markdown>
);
}
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
const render = async (text: string) =>
act(async () =>
root!.render(
<StreamingWords stream={paced(text)}>
<Body text={text} />
</StreamingWords>,
),
);
await render("");
await render("| same | same |\n");
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 250));
});
await render("| same | same |\n| --- | --- |\n| same | next |\n");
const words = Array.from(host.querySelectorAll("[data-word-fade]"), (word) => word.textContent);
expect(words).toEqual(["same", "next"]);
});
it("continues one front across a word boundary instead of restarting it", async () => {
host = document.createElement("div");
document.body.append(host);
root = createRoot(host);
const content = (value: string) => (
<StreamingWords stream={paced(value)}>
<WordFadeText style={textStyle}>{value}</WordFadeText>
</StreamingWords>
);
await act(async () => root!.render(content("")));
await act(async () => root!.render(content("abcdefgh ")));
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 60));
});
await act(async () => root!.render(content("abcdefgh ijkl ")));
for (const animation of host.getAnimations({ subtree: true })) animation.pause();
const letters = Array.from(
host.querySelectorAll<HTMLElement>("[data-word-fade] > span"),
(el) => ({
text: el.textContent,
left: el.getBoundingClientRect().left,
start: Number(el.dataset.fadeStart),
opacity: Number(getComputedStyle(el).opacity),
}),
).sort((a, b) => a.left - b.left);
expect(letters.map((l) => l.text).join("")).toBe("abcdefghijkl");
for (let index = 1; index < letters.length; index++) {
const label = `${letters[index]!.text} after ${letters[index - 1]!.text}`;
expect(letters[index]!.start, label).toBeGreaterThanOrEqual(letters[index - 1]!.start);
expect(letters[index]!.opacity, label).toBeLessThanOrEqual(letters[index - 1]!.opacity + 0.01);
}
// "i" starts after "h", and the front keeps moving inside each word.
expect(letters[8]!.start).toBeGreaterThan(letters[7]!.start);
expect(letters[1]!.start).toBeGreaterThan(letters[0]!.start);
});
it("removes the final word's animation spans without another text update", async () => {
const surface = await reveal("final");
await act(async () => {
await new Promise((resolve) => setTimeout(resolve, 250));
});
expect(surface.querySelectorAll("[data-word-fade]")).toHaveLength(0);
expect(surface.textContent).toBe("final");
});
-1
View File
@@ -1 +0,0 @@
export { WordFadeText } from "./text.android";
-22
View File
@@ -1,22 +0,0 @@
import { createContext, useContext } from "react";
import { StyleSheet, type TextProps } from "react-native";
import { UITextView } from "react-native-uitextview";
import { useWordFadeSurface } from ".";
import { WordFadeHost } from "./native-host";
import { hostTextStyle } from "./internal/host-style";
const TextAncestor = createContext(false);
export function WordFadeText(props: TextProps) {
const nested = useContext(TextAncestor);
const surface = useWordFadeSurface();
if (nested) return <UITextView {...props} uiTextView selectable />;
const style = hostTextStyle(StyleSheet.flatten(props.style) ?? {});
return (
<TextAncestor value={true}>
<WordFadeHost ranges={surface.ranges} style={style.host}>
<UITextView {...props} uiTextView selectable style={style.text} />
</WordFadeHost>
</TextAncestor>
);
}
-183
View File
@@ -1,183 +0,0 @@
import {
Children,
useCallback,
useInsertionEffect,
useLayoutEffect,
useRef,
useMemo,
memo,
useState,
type AnimationEvent,
type CSSProperties,
} from "react";
import { Text, type TextProps } from "react-native";
// Keep the same grapheme fades on browsers without Intl.Segmenter.
import { graphemeSegments } from "unicode-segmenter/grapheme";
import { useWordFadeSurface } from ".";
import { FADE_DURATION_MS } from "./internal/reveal";
let stylesheet: HTMLStyleElement | null = null;
let stylesheetUsers = 0;
// Use the browser's shaped glyph positions, including bidi text. Batch new words
// before paint so a burst performs all layout reads before any style writes.
const pendingWords = new Set<HTMLSpanElement>();
let positioningScheduled = false;
function positionWord(word: HTMLSpanElement) {
pendingWords.add(word);
if (positioningScheduled) return;
positioningScheduled = true;
queueMicrotask(() => {
positioningScheduled = false;
const positions = Array.from(pendingWords)
.filter((item) => item.isConnected)
.map((item) =>
Array.from(item.children, (child) => ({
element: child as HTMLSpanElement,
rect: child.getBoundingClientRect(),
})).sort((a, b) => a.rect.top - b.rect.top || a.rect.left - b.rect.left),
);
pendingWords.clear();
for (const letters of positions) {
letters.forEach(({ element }, index) => {
const host = element.parentElement!.dataset;
const startedAt = Number(host.fadeStarted);
const offset = (index / letters.length) * Number(host.fadeSpan);
element.style.animationDelay = `${offset}ms`;
// Pin the word to the document timeline. A CSS animation otherwise
// starts at whichever frame first resolves its style, which is a
// different skew for every word and enough to move the front backwards
// across a word boundary.
for (const animation of element.getAnimations()) {
animation.startTime = startedAt - performance.timeOrigin;
}
// Absolute start time in wall-clock ms; tests read it to check the front
// never moves backwards across word boundaries.
element.dataset.fadeStart = String(startedAt + offset);
element.dataset.fadeLast = index === letters.length - 1 ? "true" : "false";
});
}
});
}
function FadingString({ text }: { text: string }) {
const surface = useWordFadeSurface();
const [finished, setFinished] = useState(0);
const onAnimationEnd = useCallback((event: AnimationEvent<HTMLSpanElement>) => {
if (!(event.target instanceof HTMLElement) || event.target.dataset.fadeLast !== "true") return;
const word = event.currentTarget.dataset;
const deadline = Number(word.fadeStarted) + Number(word.fadeSpan) + FADE_DURATION_MS;
setFinished((current) => Math.max(current, deadline));
}, []);
const parts = [];
let end = 0;
for (const word of surface.ranges) {
if (word.startedAt + word.spanMs + FADE_DURATION_MS <= finished) continue;
parts.push(text.slice(end, word.start));
parts.push(
<FadingWord
key={`${word.start}:${word.startedAt}`}
start={word.start}
startedAt={word.startedAt}
spanMs={word.spanMs}
text={text.slice(word.start, word.end)}
onAnimationEnd={onAnimationEnd}
/>,
);
end = word.end;
}
parts.push(text.slice(end));
return parts;
}
interface FadingWordProps {
start: number;
startedAt: number;
spanMs: number;
text: string;
onAnimationEnd: (event: AnimationEvent<HTMLSpanElement>) => void;
}
const FadingWord = memo(function FadingWord({
start,
startedAt,
spanMs,
text,
onAnimationEnd,
}: FadingWordProps) {
const letters = useMemo(() => Array.from(graphemeSegments(text)), [text]);
const ref = useRef<HTMLSpanElement>(null);
useLayoutEffect(() => {
const word = ref.current!;
word.dataset.fadeStarted = String(startedAt);
word.dataset.fadeSpan = String(spanMs);
positionWord(word);
return () => {
pendingWords.delete(word);
};
}, [text, startedAt, spanMs]);
return (
<span ref={ref} data-word-fade={start} onAnimationEnd={onAnimationEnd}>
{letters.map((letter, index) => (
<FadingLetter
key={letter.index}
text={letter.segment}
delayMs={(index / letters.length) * spanMs}
last={index === letters.length - 1}
/>
))}
</span>
);
});
const FadingLetter = memo(function FadingLetter({
text,
delayMs,
last,
}: {
text: string;
delayMs: number;
last: boolean;
}) {
const style = useMemo<CSSProperties>(
() => ({
animationName: "paseo-word-fade",
animationDuration: `${FADE_DURATION_MS}ms`,
animationDelay: `${delayMs}ms`,
animationTimingFunction: "linear",
animationFillMode: "both",
}),
[delayMs],
);
return (
<span data-fade-last={last ? "true" : undefined} style={style}>
{text}
</span>
);
});
export function WordFadeText({ children, ...props }: TextProps) {
useInsertionEffect(() => {
stylesheetUsers++;
if (!stylesheet) {
stylesheet = document.createElement("style");
stylesheet.textContent =
"@keyframes paseo-word-fade { from { opacity: 0 } to { opacity: 1 } }";
document.head.appendChild(stylesheet);
}
return () => {
if (--stylesheetUsers === 0) {
stylesheet?.remove();
stylesheet = null;
}
};
}, []);
return (
<Text {...props}>
{Children.map(children, (child) =>
typeof child === "string" ? <FadingString text={child} /> : child,
)}
</Text>
);
}
-37
View File
@@ -1,37 +0,0 @@
diff --git a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java
index f708543..26df5e0 100644
--- a/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java
+++ b/node_modules/react-native/ReactAndroid/src/main/java/com/facebook/react/views/scroll/ReactScrollView.java
@@ -29,6 +29,7 @@ import android.view.ViewGroup;
import android.view.accessibility.AccessibilityNodeInfo;
import android.widget.OverScroller;
import android.widget.ScrollView;
+import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.core.view.ViewCompat;
@@ -425,12 +426,23 @@ public class ReactScrollView extends ScrollView
*/
@Override
public void requestChildFocus(View child, View focused) {
- if (focused != null) {
+ if (focused != null && shouldScrollToFocusedChild(focused)) {
scrollToChild(focused);
}
super.requestChildFocus(child, focused);
}
+ private boolean shouldScrollToFocusedChild(View focused) {
+ if (getScaleY() >= 0 || !focused.isInTouchMode() || !(focused instanceof TextView)) {
+ return true;
+ }
+
+ // Selectable text takes focus when tapped. In inverted lists, scrolling to that focus uses
+ // untransformed coordinates and unexpectedly moves the timeline.
+ TextView textView = (TextView) focused;
+ return !textView.isTextSelectable() || textView.onCheckIsTextEditor();
+ }
+
private int getScrollDelta(View descendent) {
descendent.getDrawingRect(mTempRect);
offsetDescendantRectToMyCoords(descendent, mTempRect);
-4
View File
@@ -12,10 +12,6 @@ const patchedPackages = [
nodeModulesPath: "node_modules/react-native-markdown-display",
patchPrefix: "react-native-markdown-display+",
},
{
nodeModulesPath: "node_modules/react-native",
patchPrefix: "react-native+",
},
// Remove after react-native-unistyles ships
// https://github.com/jpudysz/react-native-unistyles/pull/1203.
{