Compare commits

..
Author SHA1 Message Date
Mohamed Boudra bd1b491dcb fix(app): keep layout sized to the window after rotation
A style update racing a window resize could recommit old root constraints. Rebuild the style update from the current root on each commit attempt.
2026-10-31 12:59:15 +01:00
Mohamed Boudra fd65985e9d fix(android): resize header fades after rotation (#8349)
Clear Android SVG descendant geometry caches when the canvas resizes. Cover growth and shrinkage with a native pixel-rendering regression.
2026-10-31 14:45:13 +07:00
Mohamed Boudra 254166e7c1 feat(terminal): add native selection handles (#8348)
* feat(terminal): add native selection handles

* fix(terminal): preserve selection at row boundaries

* fix(terminal): preserve selection boundary when handles cross
2026-10-31 14:23:10 +07:00
39 changed files with 1099 additions and 1185 deletions
+12
View File
@@ -61,6 +61,18 @@ Gradle auto-fetches the platform/build-tools it needs once licenses are accepted
## Local build + install
The `react-native-svg@15.15.4` patch prevents header fades retaining their old painted size after
rotation. Rebuild native Android after changing it; Metro reloads cannot update this code. Keep the
resize hunks until upstream invalidates descendant geometry on canvas resize. Its instrumentation
regression renders real gradient pixels across growing and shrinking layouts, including a nested
group. From `packages/app/android`, run it on your selected emulator:
```bash
ANDROID_SERIAL=emulator-5562 ./gradlew :react-native-svg:connectedDebugAndroidTest \
-Pandroid.testInstrumentationRunnerArguments.class=com.horcrux.svg.SvgResizeTest \
--no-daemon --max-workers=2 -Dorg.gradle.parallel=false
```
The locked `expo-modules-core@57.0.23` needs the patch installed by `npm install`.
It backports
[Expo's Activity-result launcher fix](https://github.com/expo/expo/pull/49634): changing
@@ -1,80 +0,0 @@
import { expect, test } from "../support/fixtures";
import { composerLocator, expectComposerVisible } from "../support/helpers/composer";
import {
discardDictation,
expectEmptyDictation,
installSyntheticMicrophone,
observeDictation,
recordDictation,
retryDictation,
} from "../support/helpers/dictation";
import { openAgentRoute, seedMockAgentWorkspace } from "../support/helpers/mock-agent";
// Silence is finalized by the real daemon without calling a transcription service.
// A closed local endpoint makes an unexpected transcription request fail the test.
test.use({
e2eDaemonEnvironment: {
PASEO_DICTATION_ENABLED: "1",
PASEO_DICTATION_SILENCE_PEAK_THRESHOLD: "300",
OPENAI_API_KEY: "unused-for-silent-recordings",
OPENAI_BASE_URL: "http://127.0.0.1:9/v1",
},
});
test.describe("Dictation empty result", () => {
test("the daemon accepts the same recording through repeated empty-result retries", async ({
page,
}) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "dictation-empty-",
title: "Empty dictation",
});
await installSyntheticMicrophone(page, { silent: true });
const observed = observeDictation(page);
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await recordDictation(page, observed);
await expectEmptyDictation(page);
await retryDictation(page);
await expectEmptyDictation(page);
await retryDictation(page);
await expectEmptyDictation(page);
expect(observed.recordings).toHaveLength(3);
expect(observed.recordings[0].audio.length).toBeGreaterThan(0);
expect(observed.recordings[0].finalSeq).toBe(observed.recordings[0].audio.length - 1);
expect(observed.recordings[0].text).toBe("");
expect(observed.recordings[1]).toEqual(observed.recordings[0]);
expect(observed.recordings[2]).toEqual(observed.recordings[0]);
await discardDictation(page);
await expect(composerLocator(page)).toBeEditable();
await expect(composerLocator(page)).toHaveValue("");
} finally {
await agent.cleanup();
}
});
test.describe("narrow composer", () => {
test.use({ viewport: { width: 390, height: 844 } });
test("explains and discards an empty result", async ({ page }) => {
const agent = await seedMockAgentWorkspace({
repoPrefix: "dictation-silent-",
title: "Silent dictation",
});
await installSyntheticMicrophone(page, { silent: true });
const observed = observeDictation(page);
try {
await openAgentRoute(page, agent);
await expectComposerVisible(page);
await recordDictation(page, observed);
await expectEmptyDictation(page);
await discardDictation(page);
await expect(composerLocator(page)).toBeEditable();
await expect(composerLocator(page)).toHaveValue("");
} finally {
await agent.cleanup();
}
});
});
});
@@ -1,106 +0,0 @@
import { expect, type Page } from "../fixtures";
/**
* Replaces the browser microphone with an oscillator. `silent` keeps the stream
* running at zero gain, which is what a muted or wrongly routed input delivers.
*/
export async function installSyntheticMicrophone(
page: Page,
options: { silent?: boolean } = {},
): Promise<void> {
await page.addInitScript((silent: boolean) => {
const mediaDevices = navigator.mediaDevices;
Object.defineProperty(mediaDevices, "getUserMedia", {
configurable: true,
value: async () => {
const context = new AudioContext();
const oscillator = context.createOscillator();
const gain = context.createGain();
gain.gain.value = silent ? 0 : 1;
const destination = context.createMediaStreamDestination();
oscillator.connect(gain);
gain.connect(destination);
oscillator.start();
return destination.stream;
},
});
}, options.silent ?? false);
}
/** Observe real browser/daemon traffic without intercepting or answering it. */
export function observeDictation(page: Page) {
const streams = new Map<string, { audio: Map<number, string>; finalSeq: number }>();
const recordings: Array<{ audio: string[]; finalSeq: number; text: string }> = [];
let resolveAudio!: () => void;
const audio = new Promise<void>((resolve) => {
resolveAudio = resolve;
});
page.on("websocket", (socket) => {
socket.on("framesent", ({ payload }) => {
const request = sessionMessage(payload);
if (!request || typeof request.dictationId !== "string") return;
const id = request.dictationId;
if (request.type === "dictation_stream_start") {
streams.set(id, { audio: new Map(), finalSeq: -1 });
}
if (request.type === "dictation_stream_chunk") {
streams.get(id)!.audio.set(request.seq as number, request.audio as string);
resolveAudio();
}
if (request.type === "dictation_stream_finish") {
streams.get(id)!.finalSeq = request.finalSeq as number;
}
});
socket.on("framereceived", ({ payload }) => {
const response = sessionMessage(payload);
if (response?.type !== "dictation_stream_final") return;
const final = response.payload as { dictationId: string; text: string };
const stream = streams.get(final.dictationId)!;
recordings.push({
audio: [...stream.audio.entries()].sort(([a], [b]) => a - b).map(([, chunk]) => chunk),
finalSeq: stream.finalSeq,
text: final.text,
});
});
});
return { waitForAudio: () => audio, recordings };
}
function sessionMessage(payload: string | Buffer): Record<string, unknown> | null {
try {
const envelope = JSON.parse(typeof payload === "string" ? payload : payload.toString("utf8"));
return envelope.type === "session" ? envelope.message : null;
} catch {
return null;
}
}
export async function recordDictation(page: Page, observed: { waitForAudio(): Promise<void> }) {
await page.getByRole("button", { name: "Start dictation", exact: true }).click();
await observed.waitForAudio();
await page.getByRole("button", { name: "Insert transcription", exact: true }).click();
}
export async function expectEmptyDictation(page: Page) {
const message = page.getByText("No transcription was returned. Tap retry to try again.", {
exact: true,
});
await expect(message).toBeVisible();
const bounds = await message.boundingBox();
const cancel = await page
.getByRole("button", { name: "Cancel dictation", exact: true })
.boundingBox();
expect(bounds).not.toBeNull();
expect(cancel).not.toBeNull();
expect(bounds!.y).toBeGreaterThanOrEqual(cancel!.y);
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(cancel!.y + cancel!.height);
}
export async function retryDictation(page: Page) {
await page.getByRole("button", { name: "Retry dictation", exact: true }).click();
}
export async function discardDictation(page: Page) {
await page.getByRole("button", { name: "Cancel dictation", exact: true }).click();
}
@@ -182,8 +182,8 @@ export async function startIsolatedHostDaemon(
const spawnDaemon = async (): Promise<ChildProcess> => {
const spawnOptions: SpawnOptions = {
cwd: serverDir,
env: {
...withDisabledE2ESpeechEnv(process.env),
env: withDisabledE2ESpeechEnv({
...process.env,
...options.environment,
PASEO_HOME: paseoHome,
PASEO_SERVER_ID: serverId,
@@ -192,7 +192,7 @@ export async function startIsolatedHostDaemon(
PASEO_RELAY_ENABLED: options.mutableRelay ? undefined : "0",
PASEO_NODE_ENV: "development",
NODE_ENV: "development",
},
}),
stdio: ["ignore", "ignore", "pipe"],
detached: false,
};
@@ -10,6 +10,7 @@ import androidx.test.platform.app.InstrumentationRegistry
import org.json.JSONArray
import org.json.JSONObject
import org.junit.Assert.assertTrue
import org.junit.Assert.assertEquals
import org.junit.Test
import org.junit.runner.RunWith
@@ -55,6 +56,40 @@ class TerminalRowLayoutTest {
}
}
@Test
fun selectionCompositesOverCellAndTerminalBackgroundsWithoutChangingForeground() {
val red = 0xffff0000.toInt()
val green = 0xff00aa00.toInt()
val overlay = 0x472684ff
val source = TerminalRowSource.decode("1,1;1,4294901760,4278233600,0;M")
val layout = TerminalRowLayout(source, font, TerminalCustomGlyphs.EMPTY)
paints.selectionBackground = overlay
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
val actual = raster { canvas ->
canvas.drawColor(terminalBackground)
layout.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
}
val expected = raster { canvas ->
canvas.drawColor(terminalBackground)
canvas.drawColor(green)
canvas.drawColor(overlay)
}
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
assertTrue("selected glyph retains its red foreground", (0 until font.cellHeightPx).any { y ->
(0 until font.cellWidthPx).any { x -> actual.getPixel(x, y) == red }
})
}
val defaultBackground = TerminalRowLayout(TerminalRowSource.decode("1,1;1,4294901760,0,0;M"), font, TerminalCustomGlyphs.EMPTY)
for (terminalBackground in listOf(0xffffffff.toInt(), 0xff000000.toInt())) {
val actual = raster { canvas ->
canvas.drawColor(terminalBackground)
defaultBackground.drawSelected(canvas, 0, TerminalSelection(TerminalSelectionRecord()), paints)
}
val expected = raster { canvas -> canvas.drawColor(terminalBackground); canvas.drawColor(overlay) }
assertEquals(expected.getPixel(0, 0), actual.getPixel(0, 0))
}
}
private fun starts(row: JSONObject, key: String): List<Int> =
row.getJSONArray(key).let { starts -> List(starts.length()) { starts.getInt(it) } }
@@ -63,7 +63,6 @@ class PaseoTerminalView(context: Context, appContext: AppContext) : ExpoView(con
backgroundColor = argb(value.background)
cursorColor = argb(value.cursor)
paints.selectionBackground = argb(value.selectionBackground)
paints.selectionForeground = argb(value.selectionForeground)
invalidate()
}
@@ -27,7 +27,6 @@ class TerminalThemeRecord : Record {
@Field val background: Double = 0.0
@Field val cursor: Double = 0.0
@Field val selectionBackground: Double = 0.0
@Field val selectionForeground: Double = 0.0
}
class TerminalCustomGlyphRecord : Record {
@@ -23,7 +23,7 @@ private const val CLIP_UNBOUNDED_PX = 100_000f
* either half alone would show an explicit virama. Each resulting group is drawn from the shaped
* run (in context) at its first cell's column, centred in the columns it owns, and scaled down only when
* its glyphs are wider than those columns. Colors never split groups or reshape the row: a group
* whose cells differ in color or selection is drawn once per color, clipped to those cells' columns.
* whose cells differ in color is drawn once per color, clipped to those cells' columns.
*/
internal class TerminalRowLayout(
val source: TerminalRowSource,
@@ -195,7 +195,7 @@ internal class TerminalRowLayout(
canvas.drawRenderNode(recorded)
}
/** Draws the row with selected cells in selection colors. Selection never reshapes the row. */
/** Draws backgrounds, the selection overlay, then text with its original colors. */
fun drawSelected(canvas: Canvas, row: Int, selection: TerminalSelection, paints: TerminalPaints) {
drawContent(canvas, row, selection, paints)
}
@@ -213,17 +213,17 @@ internal class TerminalRowLayout(
val cellHeight = font.cellHeightPx.toFloat()
val fill = paints.fill
for (cell in 0 until cellCount) {
val background =
if (selected(row, selection, cell)) paints.selectionBackground else cellBackground[cell]
if (background ushr 24 == 0) continue
fill.color = background
canvas.drawRect(
cellCol[cell] * cellWidth,
0f,
(cellCol[cell] + cellSpan[cell]) * cellWidth,
cellHeight,
fill,
)
val left = cellCol[cell] * cellWidth
val right = (cellCol[cell] + cellSpan[cell]) * cellWidth
val background = cellBackground[cell]
if (background ushr 24 != 0) {
fill.color = background
canvas.drawRect(left, 0f, right, cellHeight, fill)
}
if (selected(row, selection, cell)) {
fill.color = paints.selectionBackground
canvas.drawRect(left, 0f, right, cellHeight, fill)
}
}
val baseline = font.baselinePx.toFloat()
@@ -234,9 +234,9 @@ internal class TerminalRowLayout(
val paint = font.paint(cellFlags[group.firstCell])
var first = group.firstCell
while (first <= group.lastCell) {
val color = foreground(row, selection, first, paints)
val color = cellForeground[first]
var last = first
while (last < group.lastCell && foreground(row, selection, last + 1, paints) == color) last += 1
while (last < group.lastCell && cellForeground[last + 1] == color) last += 1
// Only edges between differently colored cells clip; the group's outer edges may overhang.
val clipped = first > group.firstCell || last < group.lastCell
canvas.save()
@@ -257,7 +257,7 @@ internal class TerminalRowLayout(
for (cell in 0 until cellCount) {
val glyph = cellGlyph[cell]
val flags = cellFlags[cell]
val foreground = foreground(row, selection, cell, paints)
val foreground = cellForeground[cell]
if (glyph != null) {
fill.color = foreground
glyph.draw(canvas, cellCol[cell] * cellWidth, cellSpan[cell] * cellWidth, cellHeight, font.density, fill)
@@ -291,9 +291,6 @@ internal class TerminalRowLayout(
canvas.drawTextRun(text, group.start, group.end, group.contextStart, group.contextEnd, x, baseline, false, paint)
}
private fun foreground(row: Int, selection: TerminalSelection?, cell: Int, paints: TerminalPaints): Int =
if (selected(row, selection, cell)) paints.selectionForeground else cellForeground[cell]
/** The row's text as accessibility reads it. */
fun describe(): String = source.text.trimEnd()
}
@@ -301,6 +298,5 @@ internal class TerminalRowLayout(
/** Paint state shared by every row of one view. */
internal class TerminalPaints {
val fill = Paint(Paint.ANTI_ALIAS_FLAG)
var selectionBackground: Int = 0xFFFFFF00.toInt()
var selectionForeground: Int = 0xFF000000.toInt()
var selectionBackground: Int = 0x472684FF
}
@@ -43,7 +43,6 @@ export interface PaseoTerminalTheme {
background: number;
cursor: number;
selectionBackground: number;
selectionForeground: number;
}
export interface PaseoTerminalCellMetrics {
@@ -24,8 +24,7 @@ final class PaseoTerminalView: ExpoView {
private var background = TerminalColor(Double(0xff00_0000))
private var cursorColor = TerminalColor(Double(0xffff_ffff))
private var rowPaint = TerminalRowPaint(
selectionBackground: TerminalColor(Double(0xffff_ff00)),
selectionForeground: TerminalColor(Double(0xff00_0000)))
selectionBackground: TerminalColor(Double(0x4726_84ff)))
private var rowCache: [String: TerminalRowLayout] = [:]
required init(appContext: AppContext? = nil) {
@@ -44,8 +43,7 @@ final class PaseoTerminalView: ExpoView {
background = TerminalColor(record.background)
cursorColor = TerminalColor(record.cursor)
rowPaint = TerminalRowPaint(
selectionBackground: TerminalColor(record.selectionBackground),
selectionForeground: TerminalColor(record.selectionForeground))
selectionBackground: TerminalColor(record.selectionBackground))
backgroundColor = UIColor(cgColor: background.cgColor())
setNeedsDisplay()
}
@@ -25,7 +25,6 @@ struct TerminalThemeRecord: Record {
@Field var background: Double = 0
@Field var cursor: Double = 0
@Field var selectionBackground: Double = 0
@Field var selectionForeground: Double = 0
}
struct TerminalCustomGlyphRecord: Record {
@@ -27,7 +27,6 @@ private let leftToRightAttributes: [NSAttributedString.Key: Any] = {
/// Colors a row is painted with besides its own styles.
struct TerminalRowPaint {
let selectionBackground: TerminalColor
let selectionForeground: TerminalColor
}
/// One row shaped once and placed on the terminal grid.
@@ -267,31 +266,33 @@ final class TerminalRowLayout {
selection?.contains(row: row, col: cellCol[cell], span: cellSpan[cell]) ?? false
}
private func foreground(_ cell: Int, row: Int, selection: TerminalSelection?, paint: TerminalRowPaint) -> TerminalColor {
isSelected(cell, row: row, selection: selection) ? paint.selectionForeground : cellForeground[cell]
}
/// Draws the row with its top edge at `top`, selected cells in selection colors.
/// Draws cell backgrounds, the selection overlay, then text with its original colors.
func draw(in context: CGContext, row: Int, top: CGFloat, selection: TerminalSelection?, paint: TerminalRowPaint) {
let cellWidth = font.cellWidth
let cellHeight = font.cellHeight
for cell in 0..<cellCount {
let background = isSelected(cell, row: row, selection: selection) ? paint.selectionBackground : cellBackground[cell]
guard background.isPainted else { continue }
context.setFillColor(background.cgColor())
context.fill(CGRect(x: CGFloat(cellCol[cell]) * cellWidth, y: top, width: CGFloat(cellSpan[cell]) * cellWidth, height: cellHeight))
let rect = CGRect(x: CGFloat(cellCol[cell]) * cellWidth, y: top, width: CGFloat(cellSpan[cell]) * cellWidth, height: cellHeight)
let background = cellBackground[cell]
if background.isPainted {
context.setFillColor(background.cgColor())
context.fill(rect)
}
if isSelected(cell, row: row, selection: selection) {
context.setFillColor(paint.selectionBackground.cgColor())
context.fill(rect)
}
}
let centerY = top + cellHeight / 2
let baselineInTextSpace = cellHeight / 2 - font.baseline
// A group whose cells differ in color or selection is drawn once per color, clipped to those
// A group whose cells differ in color is drawn once per color, clipped to those
// cells' columns. Only edges between differently colored cells clip; outer edges may overhang.
for group in groups {
var first = group.firstCell
while first <= group.lastCell {
let color = foreground(first, row: row, selection: selection, paint: paint)
let color = cellForeground[first]
var last = first
while last < group.lastCell && foreground(last + 1, row: row, selection: selection, paint: paint) == color {
while last < group.lastCell && cellForeground[last + 1] == color {
last += 1
}
context.saveGState()
@@ -315,7 +316,7 @@ final class TerminalRowLayout {
let baseline = top + font.baseline
for cell in 0..<cellCount {
let color = foreground(cell, row: row, selection: selection, paint: paint)
let color = cellForeground[cell]
let left = CGFloat(cellCol[cell]) * cellWidth
let width = CGFloat(cellSpan[cell]) * cellWidth
if let glyph = cellGlyph[cell] {
@@ -4,11 +4,9 @@ import { View, Text, Pressable } from "react-native";
import { StyleSheet, useUnistyles } from "react-native-unistyles";
import { X, ArrowUp, RefreshCcw, Check, Mic, Pencil } from "lucide-react-native";
import { useTranslation } from "react-i18next";
import type { TFunction } from "i18next";
import { VolumeMeter } from "./volume-meter";
import { FOOTER_HEIGHT } from "@/constants/layout";
import type { DictationStatus } from "@/hooks/use-dictation";
import type { DictationEmptyReason } from "@/dictation/result";
interface DictationControlsProps {
volume: number;
@@ -26,30 +24,6 @@ interface DictationControlsProps {
disabled?: boolean;
}
const EMPTY_REASON_MESSAGE_KEYS = {
"no-audio": "message.dictation.empty.noAudio",
"no-transcript": "message.dictation.empty.noTranscript",
} as const satisfies Record<DictationEmptyReason, string>;
// The line under the meter once an attempt has finished without text.
function resolveResultText(input: {
t: TFunction;
status: DictationStatus;
errorText?: string;
emptyReason?: DictationEmptyReason | null;
}): string | null {
const { t, status, errorText, emptyReason } = input;
if (status === "empty") {
return t(EMPTY_REASON_MESSAGE_KEYS[emptyReason ?? "no-transcript"]);
}
if (status !== "failed") {
return null;
}
return errorText
? t("message.dictation.failed", { error: errorText })
: t("message.dictation.failedRetry");
}
function formatDuration(seconds: number): string {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
@@ -173,26 +147,18 @@ export function DictationOverlay({
isProcessing,
status,
errorText,
emptyReason,
onCancel,
onAccept,
onAcceptAndSend,
onRetry,
onDiscard,
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & {
errorText?: string;
emptyReason?: DictationEmptyReason | null;
}) {
}: Omit<DictationControlsProps, "onStart" | "disabled" | "transcript"> & { errorText?: string }) {
const { theme } = useUnistyles();
const { t } = useTranslation();
const isEmpty = status === "empty";
// An empty result is a finished attempt the user has to acknowledge, like a failure.
const isFailed = status === "failed" || isEmpty;
const canRetry = !isEmpty || emptyReason !== "no-audio";
const isFailed = status === "failed";
const showActiveState = isRecording || isProcessing || isFailed;
const actionsDisabled = isProcessing;
const handleCancel = isFailed && onDiscard ? onDiscard : onCancel;
const resultText = resolveResultText({ t, status, errorText, emptyReason });
const containerStyle = useMemo(
() => [overlayStyles.container, { backgroundColor: theme.colors.accent }],
@@ -236,26 +202,26 @@ export function DictationOverlay({
</Pressable>
<View style={overlayStyles.centerContainer}>
{!resultText ? (
<View style={overlayStyles.meterRow}>
<VolumeMeter
volume={volume}
isMuted={false}
isSpeaking={false}
orientation="horizontal"
color={theme.colors.accentForeground}
/>
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
</View>
) : null}
<View style={overlayStyles.meterRow}>
<VolumeMeter
volume={volume}
isMuted={false}
isSpeaking={false}
orientation="horizontal"
color={theme.colors.accentForeground}
/>
<Text style={overlayTimerTextStyle}>{formatDuration(duration)}</Text>
</View>
{status === "paused" ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{t("message.dictation.paused")}
</Text>
) : null}
{resultText ? (
{isFailed ? (
<Text numberOfLines={2} style={overlayTranscriptTextStyle}>
{resultText}
{errorText
? t("message.dictation.failed", { error: errorText })
: t("message.dictation.failedRetry")}
</Text>
) : null}
</View>
@@ -266,7 +232,7 @@ export function DictationOverlay({
<LoadingSpinner size="small" color={theme.colors.accentForeground} />
</View>
) : null}
{!actionsDisabled && isFailed && canRetry ? (
{!actionsDisabled && isFailed ? (
<Pressable
onPress={onRetry}
accessibilityRole="button"
@@ -1,14 +1,13 @@
import Svg, { Path } from "react-native-svg";
import { useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from "react";
import {
Keyboard,
type LayoutChangeEvent,
PanResponder,
type PanResponderGestureState,
Pressable,
StyleSheet,
Text,
View,
type GestureResponderEvent,
type StyleProp,
type ViewStyle,
} from "react-native";
@@ -47,13 +46,10 @@ import {
type TerminalSelectionViewport,
} from "../terminal/native-renderer/terminal-selection";
import {
TERMINAL_GESTURE_LONG_PRESS_MS,
TERMINAL_GESTURE_TAP_TOLERANCE_PX,
advanceTerminalScrollGesture,
classifyTerminalGestureIntent,
resolveTerminalGestureReleaseAction,
type TerminalGestureIntent,
} from "../terminal/native-renderer/terminal-selection-gesture";
createTerminalSelectionController,
terminalSelectionHandles,
TERMINAL_SELECTION_GRIP_SIZE,
} from "../terminal/native-renderer/terminal-selection-controller";
import { resolveNativeTerminalMountAction } from "../terminal/native-renderer/terminal-mount-policy";
import {
forwardNativeTerminalKey,
@@ -87,38 +83,6 @@ const DEFAULT_XTERM_THEME: ITheme = {
foreground: "#e6e6e6",
cursor: "#e6e6e6",
};
interface NativeTerminalPanState {
lastDy: number;
rowRemainder: number;
didScroll: boolean;
didNavigate: boolean;
movedBeyondTapTolerance: boolean;
intent: TerminalGestureIntent | null;
}
interface NativeTerminalSelectionPress {
startX: number;
startY: number;
startedAt: number;
startedWithSelection: boolean;
status: "pressing" | "selecting";
}
function resolvePanResponderPoint(
press: NativeTerminalSelectionPress,
event: GestureResponderEvent,
gesture: PanResponderGestureState,
): { x: number; y: number } {
const eventDx = event.nativeEvent.locationX - press.startX;
const eventDy = event.nativeEvent.locationY - press.startY;
const dx = Math.abs(eventDx) > Math.abs(gesture.dx) ? eventDx : gesture.dx;
const dy = Math.abs(eventDy) > Math.abs(gesture.dy) ? eventDy : gesture.dy;
return {
x: press.startX + dx,
y: press.startY + dy,
};
}
function initialNativeTerminalSize(): TerminalMeasuredSize {
return { rows: MIN_NATIVE_TERMINAL_ROWS, cols: MIN_NATIVE_TERMINAL_COLS };
}
@@ -163,19 +127,14 @@ function NativeTerminalEmulator({
const outputDrainRef = useRef<NativeTerminalOutputDrain | null>(null);
const screenModelRef = useRef<TerminalScreenModel | null>(null);
const selectionModelRef = useRef<TerminalSelectionModel>(createTerminalSelectionModel());
const selectionLongPressTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const selectionPressRef = useRef<NativeTerminalSelectionPress | null>(null);
const selectionControllerRef = useRef<ReturnType<
typeof createTerminalSelectionController
> | null>(null);
const gestureStartRef = useRef({ x: 0, y: 0 });
const [cellMetrics, setCellMetrics] = useState<TerminalGridCellMetrics | null>(null);
const latestResolvedScreenRef = useRef<TerminalScreenState | null>(null);
const paintFrameRef = useRef<number | null>(null);
const outputDecoderRef = useRef(new TextDecoder());
const panStateRef = useRef<NativeTerminalPanState>({
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
});
const inputModeTrackerRef = useRef(new TerminalInputModeTracker());
const lastInputModeStateRef = useRef<TerminalInputModeState>({
...inputModeTrackerRef.current.getState(),
@@ -245,6 +204,7 @@ function NativeTerminalEmulator({
}, []);
const clearSelection = useCallback(() => {
selectionControllerRef.current?.cancel();
const snapshot = selectionModelRef.current.clear();
commitSelectionRange(snapshot.range);
}, [commitSelectionRange]);
@@ -255,20 +215,14 @@ function NativeTerminalEmulator({
const syncSelectionWithTerminal = useCallback(
(terminal: NativeHeadlessTerminal) => {
const previous = selectionModelRef.current.getSnapshot().range;
const snapshot = selectionModelRef.current.sync({ bounds: terminal.getBufferBounds() });
if (previous && !snapshot.range) selectionControllerRef.current?.cancel();
commitSelectionRange(snapshot.range);
},
[commitSelectionRange],
);
const clearSelectionLongPressTimeout = useCallback(() => {
if (selectionLongPressTimeoutRef.current === null) {
return;
}
clearTimeout(selectionLongPressTimeoutRef.current);
selectionLongPressTimeoutRef.current = null;
}, []);
const resolveVisibleRows = useCallback((terminal: NativeHeadlessTerminal): number => {
return (
resolveMeasuredNativeTerminalSize({
@@ -523,8 +477,7 @@ function NativeTerminalEmulator({
emitMeasuredSize({ source: "measure" });
return () => {
callbacksRef.current.onRendererReadyChange?.({ streamKey, isReady: false });
clearSelectionLongPressTimeout();
selectionPressRef.current = null;
selectionControllerRef.current?.cancel();
if (paintFrameRef.current !== null) {
cancelAnimationFrame(paintFrameRef.current);
paintFrameRef.current = null;
@@ -534,7 +487,7 @@ function NativeTerminalEmulator({
terminalRef.current?.dispose();
terminalRef.current = null;
};
}, [clearSelectionLongPressTimeout, emitMeasuredSize, resetTerminal, streamKey]);
}, [emitMeasuredSize, resetTerminal, streamKey]);
useEffect(() => {
if (focusRequestToken <= 0) return;
@@ -591,6 +544,7 @@ function NativeTerminalEmulator({
const handleCellMetricsChange = useCallback(
(metrics: TerminalGridCellMetrics) => {
metricsRef.current = metrics;
setCellMetrics(metrics);
emitMeasuredSize({ source: "measure" });
},
[emitMeasuredSize],
@@ -663,14 +617,6 @@ function NativeTerminalEmulator({
if (!coordinate) {
return;
}
const existingPress = selectionPressRef.current;
selectionPressRef.current = {
startX: point.x,
startY: point.y,
startedAt: existingPress?.startedAt ?? Date.now(),
startedWithSelection: existingPress?.startedWithSelection ?? false,
status: "selecting",
};
const bounds = terminal.getBufferBounds();
const wordSelection = resolveTerminalWordSelection({ terminal, coordinate });
let snapshot = selectionModelRef.current.begin({
@@ -685,222 +631,61 @@ function NativeTerminalEmulator({
[commitSelectionRange, resolveSelectionCoordinate],
);
const updateSelectionAtPoint = useCallback(
(point: { x: number; y: number }) => {
const terminal = terminalRef.current;
if (!terminal || selectionPressRef.current?.status !== "selecting") {
return;
}
const coordinate = resolveSelectionCoordinate(point);
if (!coordinate) {
return;
}
const snapshot = selectionModelRef.current.update({
coordinate,
bounds: terminal.getBufferBounds(),
});
commitSelectionRange(snapshot.range);
},
[commitSelectionRange, resolveSelectionCoordinate],
);
const handlePanResponderScroll = useCallback(
(currentDy: number) => {
const cellHeight = metricsRef.current?.cellHeight;
if (!cellHeight || cellHeight <= 0) {
return;
}
const panState = panStateRef.current;
const update = advanceTerminalScrollGesture({
state: panState,
currentDy,
cellHeight,
});
panState.lastDy = update.state.lastDy;
panState.rowRemainder = update.state.rowRemainder;
panState.didScroll = update.state.didScroll;
const { rowDelta } = update;
if (rowDelta === 0) {
return;
}
if (rowDelta > 0) {
applyScroll("up", rowDelta);
return;
}
applyScroll("down", Math.abs(rowDelta));
},
[applyScroll],
const selectionController = useMemo(
() =>
createTerminalSelectionController({
getGeometry: () => {
const viewport = resolveSelectionViewport();
const metrics = metricsRef.current;
return viewport && metrics
? { range: selectionRangeRef.current, viewport, metrics }
: null;
},
onBeginHandle: (endpoint, cols) =>
selectionModelRef.current.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
const terminal = terminalRef.current;
if (!terminal) return;
commitSelectionRange(
selectionModelRef.current.update({ coordinate, bounds: terminal.getBufferBounds() })
.range,
);
},
onLongPress: beginSelectionAtPoint,
onTap: focusTerminalFromTap,
onScroll: applyScroll,
schedule: (callback, delay) => setTimeout(callback, delay) as unknown as number,
cancel: (timer) => clearTimeout(timer),
}),
[
applyScroll,
beginSelectionAtPoint,
commitSelectionRange,
focusTerminalFromTap,
resolveSelectionViewport,
],
);
selectionControllerRef.current = selectionController;
const panResponder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => true,
onMoveShouldSetPanResponder: (_event, gesture) => {
const intent = classifyTerminalGestureIntent({
status: selectionRangeRef.current ? "selecting" : "pressing",
dx: gesture.dx,
dy: gesture.dy,
vx: gesture.vx,
vy: gesture.vy,
});
return intent === "scroll" || intent === "select";
},
onMoveShouldSetPanResponderCapture: (_event, gesture) => {
const intent = classifyTerminalGestureIntent({
status: selectionRangeRef.current ? "selecting" : "pressing",
dx: gesture.dx,
dy: gesture.dy,
vx: gesture.vx,
vy: gesture.vy,
});
return intent === "scroll" || intent === "select";
},
onPanResponderGrant: (event) => {
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
const point = {
x: event.nativeEvent.locationX,
y: event.nativeEvent.locationY,
};
const pressStatus = selectionRangeRef.current ? "selecting" : "pressing";
selectionPressRef.current = {
startX: point.x,
startY: point.y,
startedAt: Date.now(),
startedWithSelection: pressStatus === "selecting",
status: pressStatus,
};
clearSelectionLongPressTimeout();
if (pressStatus === "selecting") {
return;
}
selectionLongPressTimeoutRef.current = setTimeout(() => {
selectionLongPressTimeoutRef.current = null;
const press = selectionPressRef.current;
if (press?.status !== "pressing") {
return;
}
beginSelectionAtPoint({ x: press.startX, y: press.startY });
}, TERMINAL_GESTURE_LONG_PRESS_MS);
},
onPanResponderMove: (event, gesture) => {
const selectionPress = selectionPressRef.current;
const panState = panStateRef.current;
if (Math.hypot(gesture.dx, gesture.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) {
panState.movedBeyondTapTolerance = true;
}
if (selectionPress?.status === "selecting") {
if (panState.movedBeyondTapTolerance) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
return;
}
if (panState.intent === "scroll") {
handlePanResponderScroll(gesture.dy);
return;
}
if (panState.didNavigate) {
return;
}
if (selectionPress?.status === "pressing") {
const point = resolvePanResponderPoint(selectionPress, event, gesture);
const dx = point.x - selectionPress.startX;
const dy = point.y - selectionPress.startY;
const intent = classifyTerminalGestureIntent({
status: "pressing",
dx,
dy,
vx: gesture.vx,
vy: gesture.vy,
elapsedMs: Date.now() - selectionPress.startedAt,
});
if (intent === "tap" || intent === "pending") {
return;
}
clearSelectionLongPressTimeout();
panState.intent = intent;
if (intent === "scroll") {
selectionPressRef.current = null;
handlePanResponderScroll(gesture.dy);
return;
}
if (intent === "select") {
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
updateSelectionAtPoint(point);
return;
}
}
},
onPanResponderRelease: (event, gesture) => {
clearSelectionLongPressTimeout();
const selectionPress = selectionPressRef.current;
if (
selectionPress?.status === "selecting" &&
panStateRef.current.movedBeyondTapTolerance
) {
updateSelectionAtPoint(resolvePanResponderPoint(selectionPress, event, gesture));
}
selectionPressRef.current = null;
const { didScroll, didNavigate, movedBeyondTapTolerance } = panStateRef.current;
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
const releaseAction = resolveTerminalGestureReleaseAction({
status: selectionPress?.status ?? "idle",
startedWithSelection: selectionPress?.startedWithSelection,
didScroll,
didNavigate,
movedBeyondTapTolerance,
pressDurationMs: selectionPress ? Date.now() - selectionPress.startedAt : 0,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: terminalScreenRef.current?.scroll.mode ?? "following",
});
if (releaseAction === "select" && selectionPress) {
beginSelectionAtPoint({ x: selectionPress.startX, y: selectionPress.startY });
return;
}
if (releaseAction === "focus" || releaseAction === "clear") {
focusTerminalFromTap();
}
},
onPanResponderTerminate: () => {
clearSelectionLongPressTimeout();
selectionPressRef.current = null;
panStateRef.current = {
lastDy: 0,
rowRemainder: 0,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
intent: null,
};
const point = { x: event.nativeEvent.locationX, y: event.nativeEvent.locationY };
gestureStartRef.current = point;
selectionController.press(point);
},
onPanResponderMove: (_event, gesture) =>
selectionController.move({
x: gestureStartRef.current.x + gesture.dx,
y: gestureStartRef.current.y + gesture.dy,
}),
onPanResponderRelease: () => selectionController.release(),
onPanResponderTerminate: () => selectionController.cancel(),
}),
[
beginSelectionAtPoint,
clearSelectionLongPressTimeout,
focusTerminalFromTap,
handlePanResponderScroll,
updateSelectionAtPoint,
],
[selectionController],
);
const backgroundColor = xtermTheme.background ?? "#0b0b0b";
@@ -911,6 +696,11 @@ function NativeTerminalEmulator({
const terminalState = terminalScreen?.viewport ?? null;
const isScrolled = terminalScreen?.scroll.mode === "scrolled";
const viewport = resolveSelectionViewport();
const handles =
viewport && cellMetrics
? terminalSelectionHandles({ range: selectionRange, viewport, metrics: cellMetrics })
: [];
const terminalGrid = terminalState ? (
<TerminalGridView
state={terminalState}
@@ -927,6 +717,41 @@ function NativeTerminalEmulator({
<View onLayout={handleLayout} style={rootStyle} testID={testId}>
<View {...panResponder.panHandlers} accessible={false} style={styles.nativeSurface}>
{terminalGrid}
<View pointerEvents="none" style={StyleSheet.absoluteFill}>
{handles.map((handle) => (
<View key={handle.endpoint}>
<View
style={[
styles.selectionStem,
{
left: Math.max(
0,
Math.min(handle.x - 1, viewport!.cols * cellMetrics!.cellWidth - 2),
),
top: handle.stemTop,
height: handle.stemHeight,
},
]}
/>
<Svg
width={TERMINAL_SELECTION_GRIP_SIZE}
height={TERMINAL_SELECTION_GRIP_SIZE}
viewBox="0 0 18 18"
style={[styles.selectionGrip, { left: handle.gripX - 9, top: handle.gripY - 9 }]}
>
<Path
fill="#2684ff"
d={
handle.gripDirection === "left"
? "M18 0H9C4 0 0 4 0 9S4 18 9 18 18 14 18 9Z"
: "M0 0H9C14 0 18 4 18 9S14 18 9 18 0 14 0 9Z"
}
transform={handle.gripAbove ? "translate(0 18) scale(1 -1)" : undefined}
/>
</Svg>
</View>
))}
</View>
<TerminalInput
ref={inputRef}
isKeyboardVisible={isKeyboardVisible}
@@ -969,6 +794,8 @@ const styles = StyleSheet.create({
nativeGrid: {
flex: 1,
},
selectionGrip: { position: "absolute" },
selectionStem: { position: "absolute", width: 2, backgroundColor: "#2684ff" },
followButton: {
position: "absolute",
right: 12,
+3 -15
View File
@@ -562,7 +562,6 @@ function MessageInputOverlay({
isDictationProcessing,
dictationStatus,
dictationError,
dictationEmptyReason,
onCancelRecording,
onAcceptRecording,
onAcceptAndSendRecording,
@@ -586,7 +585,6 @@ function MessageInputOverlay({
isDictationProcessing: boolean;
dictationStatus: React.ComponentProps<typeof DictationOverlay>["status"];
dictationError: string | null;
dictationEmptyReason: React.ComponentProps<typeof DictationOverlay>["emptyReason"];
onCancelRecording: () => Promise<void>;
onAcceptRecording: () => Promise<void>;
onAcceptAndSendRecording: () => Promise<void>;
@@ -595,7 +593,6 @@ function MessageInputOverlay({
onRealtimeVoiceStop: () => void;
}) {
if (showDictationOverlay) {
const awaitsAcknowledgement = dictationStatus === "failed" || dictationStatus === "empty";
return (
<DictationOverlay
volume={dictationVolume}
@@ -604,12 +601,11 @@ function MessageInputOverlay({
isProcessing={isDictationProcessing}
status={dictationStatus}
errorText={dictationStatus === "failed" ? (dictationError ?? undefined) : undefined}
emptyReason={dictationEmptyReason}
onCancel={onCancelRecording}
onAccept={onAcceptRecording}
onAcceptAndSend={onAcceptAndSendRecording}
onRetry={awaitsAcknowledgement ? onRetryFailedRecording : undefined}
onDiscard={awaitsAcknowledgement ? onDiscardFailedRecording : undefined}
onRetry={dictationStatus === "failed" ? onRetryFailedRecording : undefined}
onDiscard={dictationStatus === "failed" ? onDiscardFailedRecording : undefined}
/>
);
}
@@ -1036,12 +1032,7 @@ function computeShouldShowDictationOverlay(
isDictationProcessing: boolean,
dictationStatus: string,
): boolean {
return (
isDictating ||
isDictationProcessing ||
dictationStatus === "failed" ||
dictationStatus === "empty"
);
return isDictating || isDictationProcessing || dictationStatus === "failed";
}
function computeIsDictationStartEnabled(
@@ -1542,7 +1533,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
duration: dictationDuration,
error: dictationError,
status: dictationStatus,
emptyReason: dictationEmptyReason,
startDictation,
cancelDictation,
confirmDictation,
@@ -2190,7 +2180,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isDictationProcessing={isDictationProcessing}
dictationStatus={dictationStatus}
dictationError={dictationError}
dictationEmptyReason={dictationEmptyReason}
onCancelRecording={handleCancelRecording}
onAcceptRecording={handleAcceptRecording}
onAcceptAndSendRecording={handleAcceptAndSendRecording}
@@ -2212,7 +2201,6 @@ export const MessageInput = forwardRef<MessageInputRef, MessageInputProps>(
isDictationProcessing,
dictationStatus,
dictationError,
dictationEmptyReason,
handleCancelRecording,
handleAcceptRecording,
handleAcceptAndSendRecording,
-49
View File
@@ -1,49 +0,0 @@
import { describe, expect, it } from "vitest";
import { completeDictation, type DictationRecording } from "./result";
class RecordedAudio implements DictationRecording {
constructor(private segments: string[]) {}
getSegmentCount() {
return this.segments.length;
}
clearAll() {
this.segments = [];
}
}
describe("completing dictation", () => {
it("keeps a recording through repeated empty results until a retry returns text", () => {
const recording = new RecordedAudio(["first PCM chunk", "second PCM chunk"]);
const empty = { finalText: " ", partialText: "", recording };
const result = completeDictation(empty);
expect(recording.getSegmentCount()).toBe(2);
expect(result).toEqual({ status: "empty", reason: "no-transcript" });
expect(completeDictation(empty)).toEqual({ status: "empty", reason: "no-transcript" });
expect(recording.getSegmentCount()).toBe(2);
expect(completeDictation({ ...empty, finalText: " recovered " })).toEqual({
status: "transcribed",
text: "recovered",
});
expect(recording.getSegmentCount()).toBe(0);
});
it("keeps usable partial text when the final response is empty", () => {
const recording = new RecordedAudio(["PCM chunk"]);
expect(
completeDictation({ finalText: "", partialText: " recognized words ", recording }),
).toEqual({
status: "transcribed",
text: "recognized words",
});
expect(recording.getSegmentCount()).toBe(0);
});
it("reports a missing recording without inventing a reason for the empty result", () => {
expect(
completeDictation({ finalText: "", partialText: "", recording: new RecordedAudio([]) }),
).toEqual({
status: "empty",
reason: "no-audio",
});
});
});
-26
View File
@@ -1,26 +0,0 @@
export type DictationEmptyReason = "no-audio" | "no-transcript";
export interface DictationRecording {
getSegmentCount(): number;
clearAll(): void;
}
export type DictationResult =
| { status: "empty"; reason: DictationEmptyReason }
| { status: "transcribed"; text: string };
export function completeDictation(input: {
finalText: string;
partialText: string;
recording: DictationRecording;
}): DictationResult {
const text = input.finalText.trim() || input.partialText.trim();
if (!text) {
return {
status: "empty",
reason: input.recording.getSegmentCount() === 0 ? "no-audio" : "no-transcript",
};
}
input.recording.clearAll();
return { status: "transcribed", text };
}
+1 -10
View File
@@ -1,14 +1,6 @@
import type { DictationEmptyReason } from "@/dictation/result";
import { i18n } from "@/i18n/i18next";
export type DictationStatus =
| "idle"
| "recording"
| "paused"
| "uploading"
| "failed"
// Finished without text; captured audio remains available for retry.
| "empty";
export type DictationStatus = "idle" | "recording" | "paused" | "uploading" | "failed";
export interface UseDictationOptions {
client: import("@getpaseo/client/internal/daemon-client").DaemonClient | null;
@@ -30,7 +22,6 @@ export interface UseDictationResult {
duration: number;
error: string | null;
status: DictationStatus;
emptyReason: DictationEmptyReason | null;
startDictation: () => Promise<void>;
cancelDictation: () => Promise<void>;
confirmDictation: () => Promise<void>;
+10 -23
View File
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { DictationStreamSender } from "@/dictation/dictation-stream-sender";
import { completeDictation, type DictationEmptyReason } from "@/dictation/result";
import { useDictationAudioSource } from "@/hooks/use-dictation-audio-source";
import { generateMessageId } from "@/types/stream";
import { AttemptGuard } from "@/utils/attempt-guard";
@@ -34,7 +33,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
const [duration, setDuration] = useState(0);
const [error, setError] = useState<string | null>(null);
const [status, setStatus] = useState<DictationStatus>("idle");
const [emptyReason, setEmptyReason] = useState<DictationEmptyReason | null>(null);
const latestPartialTranscriptRef = useRef("");
const onTranscriptRef = useRef(onTranscript);
@@ -133,16 +131,11 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
[setError],
);
const clearTranscriptState = useCallback(() => {
latestPartialTranscriptRef.current = "";
setPartialTranscript("");
setEmptyReason(null);
}, []);
const clearStreamingState = useCallback(() => {
senderRef.current?.clearAll();
clearTranscriptState();
}, [clearTranscriptState]);
latestPartialTranscriptRef.current = "";
setPartialTranscript("");
}, []);
const startNewStream = useCallback(async (reason: string) => {
await senderRef.current?.restartStream(reason);
@@ -199,20 +192,16 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
setDuration(0);
setStatus("idle");
const result = completeDictation({
finalText: text,
partialText: latestPartialTranscriptRef.current,
recording: senderRef.current!,
});
if (result.status === "empty") {
setStatus("empty");
setEmptyReason(result.reason);
const transcriptText =
text.trim().length > 0 ? text.trim() : latestPartialTranscriptRef.current.trim();
clearStreamingState();
if (!transcriptText) {
return;
}
clearTranscriptState();
onTranscriptRef.current?.(result.text, { requestId });
onTranscriptRef.current?.(transcriptText, { requestId });
},
[clearTranscriptState],
[clearStreamingState],
);
const handleDictationFailure = useCallback(
@@ -406,7 +395,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
return;
}
setError(null);
setEmptyReason(null);
setStatus("uploading");
setIsProcessing(true);
isProcessingRef.current = true;
@@ -474,7 +462,6 @@ export function useDictation(options: UseDictationOptions): UseDictationResult {
duration,
error,
status: status === "recording" && audio.isPaused ? "paused" : status,
emptyReason,
startDictation,
cancelDictation,
confirmDictation,
-4
View File
@@ -371,10 +371,6 @@ export const ar: TranslationResources = {
insertAndSend: "أدخل النسخ وأرسل",
failed: "فشل الإملاء:{{error}}",
failedRetry: "فشل الإملاء. اضغط على إعادة المحاولة.",
empty: {
noAudio: "لم يُسجَّل أي صوت. هل يستخدم تطبيق آخر الميكروفون؟",
noTranscript: "لم يتم إرجاع نص. اضغط على إعادة المحاولة.",
},
},
question: {
responseFailed: "تعذر إرسال ردك. حاول مرة أخرى.",
-4
View File
@@ -368,10 +368,6 @@ export const en = {
insertAndSend: "Insert transcription and send",
failed: "Dictation failed: {{error}}",
failedRetry: "Dictation failed. Tap retry.",
empty: {
noAudio: "No audio was recorded. Is another app using the microphone?",
noTranscript: "No transcription was returned. Tap retry to try again.",
},
},
question: {
responseFailed: "Could not send your response. Try again.",
-4
View File
@@ -376,10 +376,6 @@ export const es: TranslationResources = {
insertAndSend: "Insertar transcripción y enviar",
failed: "Fallo en el dictado:{{error}}",
failedRetry: "El dictado falló. Toca reintentar.",
empty: {
noAudio: "No se grabó audio. ¿Otra app está usando el micrófono?",
noTranscript: "No se devolvió ninguna transcripción. Toca reintentar.",
},
},
question: {
responseFailed: "No se pudo enviar tu respuesta. Inténtalo de nuevo.",
-4
View File
@@ -376,10 +376,6 @@ export const fr: TranslationResources = {
insertAndSend: "Insérer la transcription et envoyer",
failed: "Échec de la dictée : {{error}}",
failedRetry: "La dictée a échoué. Appuyez sur Réessayer.",
empty: {
noAudio: "Aucun son n'a été enregistré. Une autre app utilise-t-elle le micro ?",
noTranscript: "Aucune transcription reçue. Appuyez sur Réessayer.",
},
},
question: {
responseFailed: "Impossible d’envoyer votre réponse. Réessayez.",
-4
View File
@@ -376,10 +376,6 @@ export const ja: TranslationResources = {
insertAndSend: "文字起こしを挿入して送信",
failed: "音声入力に失敗しました: {{error}}",
failedRetry: "音声入力に失敗しました。再試行をタップしてください。",
empty: {
noAudio: "音声が録音されませんでした。別のアプリがマイクを使用していませんか?",
noTranscript: "文字起こしが返されませんでした。再試行をタップしてください。",
},
},
question: {
responseFailed: "回答を送信できませんでした。もう一度お試しください。",
-4
View File
@@ -372,10 +372,6 @@ export const ko: TranslationResources = {
insertAndSend: "전사 삽입 후 보내기",
failed: "받아쓰기 실패: {{error}}",
failedRetry: "받아쓰기에 실패했습니다. 다시 시도하려면 누르세요.",
empty: {
noAudio: "녹음된 오디오가 없습니다. 다른 앱이 마이크를 사용 중인가요?",
noTranscript: "전사 결과가 없습니다. 다시 시도하려면 누르세요.",
},
},
question: {
responseFailed: "응답을 보내지 못했습니다. 다시 시도해 주세요.",
-4
View File
@@ -375,10 +375,6 @@ export const ptBR: TranslationResources = {
insertAndSend: "Inserir transcrição e enviar",
failed: "Falha no ditado: {{error}}",
failedRetry: "Falha no ditado. Toque para tentar novamente.",
empty: {
noAudio: "Nenhum áudio foi gravado. Outro app está usando o microfone?",
noTranscript: "Nenhuma transcrição foi retornada. Toque para tentar novamente.",
},
},
question: {
responseFailed: "Não foi possível enviar sua resposta. Tente novamente.",
-4
View File
@@ -375,10 +375,6 @@ export const ru: TranslationResources = {
insertAndSend: "Вставить транскрипцию и отправить",
failed: "Диктовка не удалась: {{error}}",
failedRetry: "Диктовка не удалась. Нажмите «Повторить».",
empty: {
noAudio: "Звук не записан. Возможно, микрофон занят другим приложением?",
noTranscript: "Текст не получен. Нажмите «Повторить».",
},
},
question: {
responseFailed: "Не удалось отправить ответ. Попробуйте ещё раз.",
-4
View File
@@ -371,10 +371,6 @@ export const zhCN: TranslationResources = {
insertAndSend: "插入转写并发送",
failed: "听写失败:{{error}}",
failedRetry: "听写失败。点按重试。",
empty: {
noAudio: "未录到音频。是否有其他应用正在使用麦克风?",
noTranscript: "未返回转写文本。点按重试。",
},
},
question: {
responseFailed: "未能发送你的回复,请重试。",
@@ -48,8 +48,7 @@ export interface TerminalCellStyleResolver {
export const DEFAULT_TERMINAL_THEME = toXtermTheme(darkTheme.colors.terminal);
export const NATIVE_TERMINAL_SELECTION_COLORS = {
background: 0xffffff00,
foreground: 0xff000000,
background: 0x472684ff,
} as const;
function build256Palette(theme: ITheme): number[] {
@@ -77,7 +77,6 @@ export function TerminalGridView({
background: resolver.backgroundColor,
cursor: resolver.cursorColor,
selectionBackground: NATIVE_TERMINAL_SELECTION_COLORS.background,
selectionForeground: NATIVE_TERMINAL_SELECTION_COLORS.foreground,
}),
[resolver],
);
@@ -0,0 +1,411 @@
import { describe, expect, it } from "vitest";
import { createNativeHeadlessTerminal } from "./headless-terminal-state";
import {
createTerminalSelectionModel,
extractTerminalSelectedText,
resolveTerminalSelectionRects,
} from "./terminal-selection";
import {
createTerminalSelectionController,
terminalSelectionHandles,
} from "./terminal-selection-controller";
describe("selection handle controller", () => {
it("preserves the grab offset while moving a handle", () => {
const range = { start: { row: 1, col: 2 }, end: { row: 1, col: 5 }, coordinateEpoch: 0 };
const moves: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range,
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => {},
onMoveHandle: (coordinate) => moves.push(coordinate),
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 23, y: 15 });
controller.move({ x: 63, y: 15 });
controller.release();
expect(moves).toEqual([{ row: 1, col: 5 }]);
});
});
it("selects on hold, preserves a handle tap, and cancels an active drag", () => {
const actions: string[] = [];
let timer: (() => void) | null = null;
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: (endpoint) => actions.push(endpoint),
onMoveHandle: () => actions.push("move"),
onLongPress: () => actions.push("long"),
onTap: () => actions.push("tap"),
onScroll: () => actions.push("scroll"),
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 90, y: 80 });
(timer as (() => void) | null)?.();
controller.release();
controller.press({ x: 20, y: 25 });
controller.release();
controller.press({ x: 20, y: 25 });
controller.cancel();
controller.move({ x: 50, y: 25 });
expect(actions).toEqual(["long", "start", "start"]);
});
it("scrolls selection into history while holding a handle at the edge", () => {
let firstRow = 10;
let timer: (() => void) | null = null;
const coordinates: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: { start: { row: 12, col: 2 }, end: { row: 13, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => {},
onMoveHandle: (point) => coordinates.push(point),
onLongPress: () => {},
onTap: () => {},
onScroll: (direction) => {
if (direction === "up") firstRow--;
},
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 20, y: 25 });
controller.move({ x: 20, y: 1 });
(timer as (() => void) | null)?.();
(timer as (() => void) | null)?.();
controller.release();
expect(coordinates).toEqual([
{ row: 10, col: 2 },
{ row: 9, col: 2 },
{ row: 8, col: 2 },
]);
expect(timer).toBeNull();
});
it("scrolls outside handles without clearing selection and clears on an outside tap", () => {
const actions: string[] = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => actions.push("handle"),
onMoveHandle: () => actions.push("move"),
onLongPress: () => actions.push("long"),
onTap: () => actions.push("tap"),
onScroll: (direction, rows) => actions.push(`${direction}:${rows}`),
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 95, y: 70 });
controller.move({ x: 95, y: 90 });
controller.release();
controller.press({ x: 95, y: 70 });
controller.release();
expect(actions).toEqual(["up:2", "tap"]);
});
it("extends the selection after a long press and drag", () => {
let timer: (() => void) | null = null;
const moves: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: null,
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: () => {},
onMoveHandle: (coordinate) => moves.push(coordinate),
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 25, y: 25 });
(timer as (() => void) | null)?.();
controller.move({ x: 65, y: 35 });
controller.release();
expect(moves).toEqual([{ row: 3, col: 6 }]);
});
it.each([
{
endpoint: "start" as const,
startX: 20,
crossedX: 80,
crossedText: "gh",
crossedStart: 6,
crossedEnd: 7,
backX: 10,
backText: "bcdef",
backStart: 1,
backEnd: 5,
},
{
endpoint: "end" as const,
startX: 60,
crossedX: 10,
crossedText: "b",
crossedStart: 1,
crossedEnd: 1,
backX: 80,
backText: "cdefgh",
backStart: 2,
backEnd: 7,
},
])("tracks the $endpoint handle through crossing and back", async (scenario) => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 10 });
try {
await terminal.write("abcdefghij");
const bounds = terminal.getBufferBounds();
const model = createTerminalSelectionModel();
model.begin({ coordinate: { row: 0, col: 2 }, bounds });
model.update({ coordinate: { row: 0, col: 5 }, bounds });
const viewport = { firstRow: 0, rows: 4, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const geometry = () => ({ range: model.getSnapshot().range, viewport, metrics });
const controller = createTerminalSelectionController({
getGeometry: geometry,
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
const check = (start: number, end: number, text: string) => {
const range = model.getSnapshot().range;
expect(range).toEqual({
start: { row: 0, col: start },
end: { row: 0, col: end },
coordinateEpoch: bounds.coordinateEpoch,
});
expect(terminalSelectionHandles(geometry())).toMatchObject([
{ endpoint: "start", x: start * 10, y: 5 },
{ endpoint: "end", x: (end + 1) * 10, y: 5 },
]);
expect(resolveTerminalSelectionRects({ selection: range, viewport, metrics })).toEqual([
{
key: `0:${start}:${end}`,
x: start * 10,
y: 0,
width: (end - start + 1) * 10,
height: 10,
},
]);
expect(extractTerminalSelectedText({ terminal, selection: range })).toBe(text);
};
controller.press({ x: scenario.startX, y: 5 });
controller.move({ x: scenario.crossedX, y: 5 });
check(scenario.crossedStart, scenario.crossedEnd, scenario.crossedText);
controller.move({ x: scenario.backX, y: 5 });
check(scenario.backStart, scenario.backEnd, scenario.backText);
controller.release();
} finally {
terminal.dispose();
}
});
it.each([
{
endpoint: "end" as const,
initialRow: 0,
targetRow: 1,
targetX: 0,
expectedRow: 0,
expectedCol: 9,
text: "cdefghij",
},
{
endpoint: "start" as const,
initialRow: 1,
targetRow: 0,
targetX: 100,
expectedRow: 1,
expectedCol: 0,
text: "KLMNOP",
},
])("preserves a $endpoint boundary at a row edge", async (scenario) => {
const terminal = createNativeHeadlessTerminal({ rows: 4, cols: 10 });
try {
await terminal.write("abcdefghij\r\nKLMNOPQRST");
const bounds = terminal.getBufferBounds();
const model = createTerminalSelectionModel();
model.begin({ coordinate: { row: scenario.initialRow, col: 2 }, bounds });
model.update({ coordinate: { row: scenario.initialRow, col: 5 }, bounds });
const viewport = { firstRow: 0, rows: 4, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const geometry = () => ({ range: model.getSnapshot().range, viewport, metrics });
const controller = createTerminalSelectionController({
getGeometry: geometry,
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: scenario.endpoint === "end" ? 60 : 20, y: scenario.initialRow * 10 + 5 });
controller.move({ x: scenario.targetX, y: scenario.targetRow * 10 + 5 });
const range = model.getSnapshot().range;
expect(range?.[scenario.endpoint]).toEqual({
row: scenario.expectedRow,
col: scenario.expectedCol,
});
expect(extractTerminalSelectedText({ terminal, selection: range })).toBe(scenario.text);
expect(
terminalSelectionHandles(geometry()).find((handle) => handle.endpoint === scenario.endpoint),
).toMatchObject({
x: scenario.endpoint === "end" ? 100 : 0,
y: scenario.expectedRow * 10 + 5,
});
controller.release();
} finally {
terminal.dispose();
}
});
it("cancels a drag when buffer coordinates are invalidated", () => {
const model = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 9, coordinateEpoch: 0 };
model.begin({ coordinate: { row: 2, col: 2 }, bounds });
model.update({ coordinate: { row: 2, col: 5 }, bounds });
const viewport = { firstRow: 0, rows: 10, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const controller = createTerminalSelectionController({
getGeometry: () => ({ range: model.getSnapshot().range, viewport, metrics }),
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 20, y: 25 });
const previous = model.getSnapshot().range;
const snapshot = model.sync({ bounds: { ...bounds, coordinateEpoch: 1 } });
if (previous && !snapshot.range) controller.cancel();
controller.move({ x: 80, y: 25 });
controller.release();
expect(model.getSnapshot().range).toBeNull();
});
it("preserves a newly selected word through small movement at the viewport edge", () => {
const model = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 9, coordinateEpoch: 0 };
let timer: (() => void) | null = null;
const controller = createTerminalSelectionController({
getGeometry: () => ({
range: model.getSnapshot().range,
viewport: { firstRow: 0, rows: 10, cols: 20 },
metrics: { cellWidth: 10, cellHeight: 10 },
}),
onBeginHandle: (endpoint, cols) => model.beginEndpointDrag(endpoint, cols),
onMoveHandle: (coordinate) => {
model.update({ coordinate, bounds });
},
onLongPress: () => {
model.begin({ coordinate: { row: 0, col: 2 }, bounds });
model.update({ coordinate: { row: 0, col: 6 }, bounds });
},
onTap: () => {},
onScroll: () => {},
schedule: (callback) => {
timer = callback;
return 1;
},
cancel: () => {
timer = null;
},
});
controller.press({ x: 45, y: 5 });
const fire = timer as (() => void) | null;
timer = null;
fire?.();
controller.move({ x: 44, y: 4 });
controller.release();
expect(model.getSnapshot().range).toEqual({
start: { row: 0, col: 2 },
end: { row: 0, col: 6 },
coordinateEpoch: 0,
});
expect(timer).toBeNull();
});
it("places mirrored teardrop grips below the selected row", () => {
const handles = terminalSelectionHandles({
range: { start: { row: 2, col: 2 }, end: { row: 2, col: 5 }, coordinateEpoch: 0 },
viewport: { firstRow: 0, rows: 10, cols: 10 },
metrics: { cellWidth: 10, cellHeight: 10 },
});
expect(handles).toMatchObject([
{ endpoint: "start", x: 20, y: 25, gripX: 11, gripY: 39, gripDirection: "left" },
{ endpoint: "end", x: 60, y: 25, gripX: 69, gripY: 39, gripDirection: "right" },
]);
});
it("keeps edge grips reachable and preserves the offset from the cell when dragging a grip", () => {
const range = { start: { row: 0, col: 0 }, end: { row: 9, col: 9 }, coordinateEpoch: 0 };
const viewport = { firstRow: 0, rows: 10, cols: 10 };
const metrics = { cellWidth: 10, cellHeight: 10 };
const handles = terminalSelectionHandles({ range, viewport, metrics });
expect(handles.map(({ gripX, gripY }) => ({ gripX, gripY }))).toEqual([
{ gripX: 9, gripY: 19 },
{ gripX: 91, gripY: 81 },
]);
const moves: Array<{ row: number; col: number }> = [];
const controller = createTerminalSelectionController({
getGeometry: () => ({ range, viewport, metrics }),
onBeginHandle: () => {},
onMoveHandle: (coordinate) => moves.push(coordinate),
onLongPress: () => {},
onTap: () => {},
onScroll: () => {},
schedule: () => 1,
cancel: () => {},
});
controller.press({ x: 9, y: 19 });
controller.move({ x: 19, y: 29 });
controller.release();
expect(moves).toEqual([{ row: 1, col: 1 }]);
});
@@ -0,0 +1,208 @@
import type { TerminalGridCellMetrics } from "./terminal-grid-metrics";
import type {
TerminalBufferCoordinate,
TerminalSelectionPoint,
TerminalSelectionRange,
TerminalSelectionViewport,
} from "./terminal-selection";
export const TERMINAL_SELECTION_GRIP_SIZE = 18;
const TERMINAL_GESTURE_LONG_PRESS_MS = 450;
const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8;
type Endpoint = "start" | "end";
interface Geometry {
range: TerminalSelectionRange | null;
viewport: TerminalSelectionViewport;
metrics: TerminalGridCellMetrics;
}
interface Ports {
getGeometry(): Geometry | null;
onBeginHandle(endpoint: Endpoint, cols: number): void;
onMoveHandle(coordinate: TerminalBufferCoordinate): void;
onLongPress(point: TerminalSelectionPoint): void;
onTap(): void;
onScroll(direction: "up" | "down", rows: number): void;
schedule(callback: () => void, delay: number): number;
cancel(timer: number): void;
}
export function terminalSelectionHandles(geometry: Geometry): Array<{
endpoint: Endpoint;
x: number;
y: number;
gripX: number;
gripY: number;
gripDirection: "left" | "right";
gripAbove: boolean;
stemTop: number;
stemHeight: number;
}> {
const { range, viewport, metrics } = geometry;
if (!range) return [];
return (["start", "end"] as const).flatMap((endpoint) => {
const cell = range[endpoint];
if (cell.row < viewport.firstRow || cell.row >= viewport.firstRow + viewport.rows) return [];
const x = (cell.col + (endpoint === "end" ? 1 : 0)) * metrics.cellWidth;
const stemTop = (cell.row - viewport.firstRow) * metrics.cellHeight;
const size = TERMINAL_SELECTION_GRIP_SIZE;
const width = viewport.cols * metrics.cellWidth;
const height = viewport.rows * metrics.cellHeight;
const wantsLeft = endpoint === "start";
const gripDirection =
(wantsLeft && x >= size) || (!wantsLeft && x > width - size) ? "left" : "right";
const gripAbove = stemTop + metrics.cellHeight + size > height;
const gripTop = gripAbove ? Math.max(0, stemTop - size) : stemTop + metrics.cellHeight;
return [
{
endpoint,
x,
y: stemTop + metrics.cellHeight / 2,
gripX: x + (gripDirection === "left" ? -size / 2 : size / 2),
gripY: gripTop + size / 2,
gripDirection,
gripAbove,
stemTop,
stemHeight: metrics.cellHeight,
},
];
});
}
export function createTerminalSelectionController(ports: Ports) {
let press: {
start: TerminalSelectionPoint;
point: TerminalSelectionPoint;
endpoint: Endpoint | null;
offset: TerminalSelectionPoint;
fixed: TerminalBufferCoordinate | null;
moved: boolean;
long: boolean;
scrolling: boolean;
remainder: number;
} | null = null;
let timer: number | null = null;
function stopTimer() {
if (timer !== null) ports.cancel(timer);
timer = null;
}
function updateHandle() {
const geometry = ports.getGeometry();
if (!press || (!press.endpoint && !press.long) || !geometry) return;
const { viewport, metrics } = geometry;
const x = press.point.x - press.offset.x;
const y = press.point.y - press.offset.y;
let row =
viewport.firstRow +
Math.max(0, Math.min(viewport.rows - 1, Math.floor(y / metrics.cellHeight)));
let col = Math.floor(x / metrics.cellWidth);
if (press.endpoint && press.fixed) {
const boundary = Math.round(x / metrics.cellWidth);
const isEnd =
row > press.fixed.row || (row === press.fixed.row && boundary > press.fixed.col);
col = boundary - (isEnd ? 1 : 0);
if (col < 0 && isEnd) {
row -= 1;
col = viewport.cols - 1;
} else if (col >= viewport.cols && !isEnd) {
row += 1;
col = 0;
}
}
col = Math.max(0, Math.min(viewport.cols - 1, col));
ports.onMoveHandle({ row, col });
}
function edgeTick() {
timer = null;
const geometry = ports.getGeometry();
if (!press || (!press.endpoint && !press.long) || !geometry) return;
const y = press.point.y - press.offset.y;
const height = geometry.viewport.rows * geometry.metrics.cellHeight;
let direction: "up" | "down" | null = null;
if (y < geometry.metrics.cellHeight) direction = "up";
else if (y > height - geometry.metrics.cellHeight) direction = "down";
if (!direction) return;
ports.onScroll(direction, 1);
if (!press) return;
updateHandle();
if (press) timer = ports.schedule(edgeTick, 100);
}
return {
press(point: TerminalSelectionPoint) {
stopTimer();
const geometry = ports.getGeometry();
const handles = geometry ? terminalSelectionHandles(geometry) : [];
const handle = handles
.map((item) => ({
endpoint: item.endpoint,
x: item.x,
y: item.y,
distance: Math.hypot(point.x - item.gripX, point.y - item.gripY),
}))
.filter((item) => item.distance <= 28)
.sort((a, b) => a.distance - b.distance)[0];
press = {
start: point,
point,
endpoint: handle?.endpoint ?? null,
offset: handle ? { x: point.x - handle.x, y: point.y - handle.y } : { x: 0, y: 0 },
fixed:
handle && geometry?.range
? geometry.range[handle.endpoint === "start" ? "end" : "start"]
: null,
moved: false,
long: false,
scrolling: false,
remainder: 0,
};
if (handle && geometry) {
ports.onBeginHandle(handle.endpoint, geometry.viewport.cols);
return;
}
timer = ports.schedule(() => {
timer = null;
if (press && !press.moved) {
press.long = true;
ports.onLongPress(press.start);
}
}, TERMINAL_GESTURE_LONG_PRESS_MS);
},
move(point: TerminalSelectionPoint) {
if (!press) return;
const previous = press.point;
press.point = point;
const dx = point.x - press.start.x,
dy = point.y - press.start.y;
if (Math.hypot(dx, dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) {
press.moved = true;
if (!press.endpoint) stopTimer();
}
if (press.endpoint || press.long) {
if (press.endpoint || press.moved) {
updateHandle();
stopTimer();
if (press) timer = ports.schedule(edgeTick, 100);
}
} else if (!press.long) {
if (!press.scrolling && Math.abs(dy) > 12 && Math.abs(dy) > Math.abs(dx)) {
press.scrolling = true;
press.remainder = dy;
} else if (press.scrolling) press.remainder += point.y - previous.y;
const height = ports.getGeometry()?.metrics.cellHeight ?? 0;
if (press.scrolling && height > 0) {
const rows = Math.trunc(press.remainder / height);
press.remainder -= rows * height;
if (rows) ports.onScroll(rows > 0 ? "up" : "down", Math.abs(rows));
}
}
},
release() {
stopTimer();
if (press && !press.endpoint && !press.long && !press.moved) ports.onTap();
press = null;
},
cancel() {
stopTimer();
press = null;
},
};
}
@@ -1,354 +0,0 @@
import { describe, expect, it } from "vitest";
import {
TERMINAL_GESTURE_LONG_PRESS_MS,
TERMINAL_GESTURE_TAP_TOLERANCE_PX,
TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX,
advanceTerminalScrollGesture,
classifyTerminalGestureIntent,
resolveTerminalGestureReleaseAction,
} from "./terminal-selection-gesture";
describe("native terminal gesture policy", () => {
describe("advanceTerminalScrollGesture", () => {
it("keeps consuming a slow drag after scroll intent arms below one row", () => {
let state = { lastDy: 0, rowRemainder: 0, didScroll: false };
const rowDeltas: number[] = [];
for (const currentDy of [13, 15, 18, 22, 28]) {
const update = advanceTerminalScrollGesture({
state,
currentDy,
cellHeight: 14,
});
state = update.state;
rowDeltas.push(update.rowDelta);
}
expect({ rowDeltas, state }).toEqual({
rowDeltas: [0, 1, 0, 0, 1],
state: { lastDy: 28, rowRemainder: 0, didScroll: true },
});
});
});
describe("classifyTerminalGestureIntent", () => {
it("treats a motionless press as a tap", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: 0,
dy: 0,
vx: 0,
vy: 0,
}),
).toEqual("tap");
});
it("treats a tiny jitter within tap tolerance as a tap", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 4,
dy: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 4,
vx: 0,
vy: 0,
}),
).toEqual("tap");
});
it("leaves horizontal movement available to terminal selection", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 20,
dy: 4,
vx: 0,
vy: 0,
}),
).toEqual("pending");
});
it("does not infer selection from slow horizontal drag dwell", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40,
dy: 3,
vx: 0,
vy: 0,
elapsedMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50,
}),
).toEqual("pending");
});
it("keeps tiny horizontal jitter inside tap tolerance as a tap", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 1,
dy: 1,
vx: 2,
vy: 0,
elapsedMs: 20,
}),
).toEqual("tap");
});
it("keeps horizontal movement below navigation distance pending", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 10,
dy: 2,
vx: 0,
vy: 0,
}),
).toEqual("pending");
});
it("treats a clearly vertical drag as scroll", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: 4,
dy: TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX + 5,
vx: 0,
vy: 0.2,
}),
).toEqual("scroll");
});
it("does not navigate on a fast rightward swipe", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40,
dy: 4,
vx: 2,
vy: 0,
}),
).toEqual("pending");
});
it("does not navigate on a slow rightward swipe", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40,
dy: 4,
vx: 0,
vy: 0,
}),
).toEqual("pending");
});
it("does not navigate on a fast leftward swipe", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: -(TERMINAL_GESTURE_TAP_TOLERANCE_PX + 40),
dy: -4,
vx: -2,
vy: 0,
}),
).toEqual("pending");
});
it("does not navigate while movement stays inside tap tolerance", () => {
expect(
classifyTerminalGestureIntent({
status: "pressing",
dx: TERMINAL_GESTURE_TAP_TOLERANCE_PX - 1,
dy: 2,
vx: 2,
vy: 0,
}),
).toEqual("tap");
});
it("keeps selection intent once selection is active", () => {
expect(
classifyTerminalGestureIntent({
status: "selecting",
dx: 100,
dy: 50,
vx: 2,
vy: 1,
}),
).toEqual("select");
});
});
describe("resolveTerminalGestureReleaseAction", () => {
it("focuses the terminal after a normal tap", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("focus");
});
it("focuses the terminal after a normal tap while scrolled", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "scrolled",
}),
).toEqual("focus");
});
it("starts selection after a long press", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("select");
});
it("starts selection after a 500ms hold", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 500,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("select");
});
it("keeps a 400ms press as a tap", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 400,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("focus");
});
it("does nothing after a scroll gesture", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: true,
didNavigate: false,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "scrolled",
}),
).toEqual("none");
});
it("does nothing after a navigation swipe", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: true,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("does nothing while actively selecting", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "selecting",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("clears an existing selection after a short tap", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "selecting",
startedWithSelection: true,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("clear");
});
it("preserves a word selection created by the current long press", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "selecting",
startedWithSelection: false,
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: TERMINAL_GESTURE_LONG_PRESS_MS + 50,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("does not focus after an undecided fast drag", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "pressing",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: true,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
it("does nothing from idle", () => {
expect(
resolveTerminalGestureReleaseAction({
status: "idle",
didScroll: false,
didNavigate: false,
movedBeyondTapTolerance: false,
pressDurationMs: 80,
longPressMs: TERMINAL_GESTURE_LONG_PRESS_MS,
scrollMode: "following",
}),
).toEqual("none");
});
});
});
@@ -1,108 +0,0 @@
export type TerminalGestureStatus = "idle" | "pressing" | "selecting";
export type TerminalGestureIntent = "tap" | "pending" | "select" | "scroll";
export type TerminalGestureScrollMode = "following" | "scrolled";
export interface TerminalGestureMoveInput {
status: TerminalGestureStatus;
dx: number;
dy: number;
vx: number;
vy: number;
elapsedMs?: number;
}
export interface TerminalGestureReleaseInput {
status: TerminalGestureStatus;
startedWithSelection?: boolean;
didScroll: boolean;
didNavigate: boolean;
movedBeyondTapTolerance: boolean;
pressDurationMs: number;
longPressMs: number;
scrollMode: TerminalGestureScrollMode;
}
export interface TerminalScrollGestureState {
lastDy: number;
rowRemainder: number;
didScroll: boolean;
}
export interface TerminalScrollGestureUpdate {
state: TerminalScrollGestureState;
rowDelta: number;
}
export type TerminalGestureAction = "none" | "focus" | "select" | "clear";
export const TERMINAL_GESTURE_TAP_TOLERANCE_PX = 8;
export const TERMINAL_GESTURE_LONG_PRESS_MS = 450;
export const TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX = 12;
export function classifyTerminalGestureIntent(
input: TerminalGestureMoveInput,
): TerminalGestureIntent {
if (input.status === "selecting") {
return "select";
}
const absDx = Math.abs(input.dx);
const absDy = Math.abs(input.dy);
if (absDy > TERMINAL_GESTURE_VERTICAL_SCROLL_THRESHOLD_PX && absDy > absDx) {
return "scroll";
}
if (Math.hypot(input.dx, input.dy) > TERMINAL_GESTURE_TAP_TOLERANCE_PX) {
return "pending";
}
return "tap";
}
export function advanceTerminalScrollGesture(input: {
state: TerminalScrollGestureState;
currentDy: number;
cellHeight: number;
}): TerminalScrollGestureUpdate {
const deltaY = input.currentDy - input.state.lastDy;
const accumulatedRows = input.state.rowRemainder + deltaY;
const rowDelta = Math.trunc(accumulatedRows / input.cellHeight);
return {
state: {
lastDy: input.currentDy,
rowRemainder: accumulatedRows - rowDelta * input.cellHeight,
didScroll: input.state.didScroll || rowDelta !== 0,
},
rowDelta,
};
}
export function resolveTerminalGestureReleaseAction(
input: TerminalGestureReleaseInput,
): TerminalGestureAction {
if (input.didScroll || input.didNavigate) {
return "none";
}
if (input.status === "selecting") {
if (!input.startedWithSelection) {
return "none";
}
return input.movedBeyondTapTolerance ? "none" : "clear";
}
if (input.status !== "pressing") {
return "none";
}
if (input.pressDurationMs >= input.longPressMs) {
return "select";
}
if (input.movedBeyondTapTolerance) {
return "none";
}
return "focus";
}
@@ -483,3 +483,51 @@ describe("native terminal selection", () => {
expect(copied).toEqual([]);
});
});
describe("endpoint dragging", () => {
it.each([
{
endpoint: "start" as const,
fixed: { row: 2, col: 9 },
target: { row: 3, col: 2 },
expected: { row: 3, col: 0 },
},
{
endpoint: "end" as const,
fixed: { row: 2, col: 0 },
target: { row: 1, col: 7 },
expected: { row: 1, col: 9 },
},
])(
"preserves the fixed boundary across a row edge for $endpoint",
({ endpoint, fixed, target, expected }) => {
const selection = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 10, coordinateEpoch: 1 };
selection.begin({ coordinate: fixed, bounds });
selection.update({ coordinate: fixed, bounds });
selection.beginEndpointDrag(endpoint, 10);
selection.update({ coordinate: target, bounds });
expect(selection.getSnapshot().range?.[endpoint === "start" ? "start" : "end"]).toEqual(
expected,
);
},
);
it.each(["start", "end"] as const)(
"keeps the opposite endpoint fixed as %s crosses it",
(endpoint) => {
const selection = createTerminalSelectionModel();
const bounds = { oldestRow: 0, newestRow: 10, coordinateEpoch: 1 };
selection.begin({ coordinate: { row: 2, col: 2 }, bounds });
selection.update({ coordinate: { row: 3, col: 4 }, bounds });
selection.beginEndpointDrag(endpoint, 10);
const coordinate = endpoint === "start" ? { row: 4, col: 5 } : { row: 1, col: 1 };
selection.update({ coordinate, bounds });
expect(selection.getSnapshot().range).toEqual(
endpoint === "start"
? { start: { row: 3, col: 5 }, end: coordinate, coordinateEpoch: 1 }
: { start: coordinate, end: { row: 2, col: 1 }, coordinateEpoch: 1 },
);
},
);
});
@@ -25,6 +25,7 @@ export interface TerminalSelectionBounds {
export interface TerminalSelectionModel {
begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot;
update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot;
beginEndpointDrag(endpoint: "start" | "end", cols: number): void;
clear(): TerminalSelectionSnapshot;
sync(input: TerminalSelectionSyncInput): TerminalSelectionSnapshot;
getSnapshot(): TerminalSelectionSnapshot;
@@ -252,6 +253,7 @@ export function resolveTerminalSelectionRects(
export function createTerminalSelectionModel(): TerminalSelectionModel {
let state: TerminalSelectionState = { anchor: null, range: null };
let drag: { endpoint: "start" | "end"; cols: number } | null = null;
function snapshot(): TerminalSelectionSnapshot {
return { range: state.range };
@@ -259,18 +261,31 @@ export function createTerminalSelectionModel(): TerminalSelectionModel {
function setSelection(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot {
const coordinate = clampCoordinate(input.coordinate, input.bounds);
const anchor = state.anchor ?? coordinate;
const fixed = state.anchor ?? coordinate;
let anchor = fixed;
if (drag?.endpoint === "start" && compareCoordinates(coordinate, fixed) > 0) {
anchor =
fixed.col + 1 < drag.cols
? { row: fixed.row, col: fixed.col + 1 }
: { row: fixed.row + 1, col: 0 };
} else if (drag?.endpoint === "end" && compareCoordinates(coordinate, fixed) < 0) {
anchor =
fixed.col > 0
? { row: fixed.row, col: fixed.col - 1 }
: { row: fixed.row - 1, col: drag.cols - 1 };
}
const range = normalizeTerminalSelection({
anchor,
focus: coordinate,
coordinateEpoch: input.bounds.coordinateEpoch,
});
state = { anchor, range };
state = { anchor: fixed, range };
return snapshot();
}
return {
begin(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot {
drag = null;
const coordinate = clampCoordinate(input.coordinate, input.bounds);
state = {
anchor: coordinate,
@@ -285,7 +300,14 @@ export function createTerminalSelectionModel(): TerminalSelectionModel {
update(input: TerminalSelectionUpdateInput): TerminalSelectionSnapshot {
return setSelection(input);
},
beginEndpointDrag(endpoint, cols) {
if (state.range) {
drag = { endpoint, cols };
state = { ...state, anchor: state.range[endpoint === "start" ? "end" : "start"] };
}
},
clear(): TerminalSelectionSnapshot {
drag = null;
state = { anchor: null, range: null };
return snapshot();
},
@@ -293,6 +315,7 @@ export function createTerminalSelectionModel(): TerminalSelectionModel {
if (!state.range || rangeIsSafe(state.range, input.bounds)) {
return snapshot();
}
drag = null;
state = { anchor: null, range: null };
return snapshot();
},
+127
View File
@@ -59,3 +59,130 @@ index 0c854d6..7dc2828 100644
}
return props.transform as TransformsStyle['transform'];
}
diff --git a/node_modules/react-native-svg/android/build.gradle b/node_modules/react-native-svg/android/build.gradle
--- a/node_modules/react-native-svg/android/build.gradle
+++ b/node_modules/react-native-svg/android/build.gradle
@@ -103,6 +103,7 @@
}
defaultConfig {
+ testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
minSdkVersion safeExtGet('minSdkVersion', 16)
//noinspection OldTargetApi
targetSdkVersion safeExtGet('targetSdkVersion', 28)
@@ -136,6 +137,8 @@
}
dependencies {
+ androidTestImplementation 'androidx.test:runner:1.6.2'
+ androidTestImplementation 'androidx.test.ext:junit:1.2.1'
implementation 'com.facebook.react:react-native:+'
implementation("com.facebook.fresco:fresco:${FRESCO_VERSION}") {
exclude group: 'com.facebook.soloader'
diff --git a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java
--- a/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java
+++ b/node_modules/react-native-svg/android/src/main/java/com/horcrux/svg/SvgView.java
@@ -159,6 +159,8 @@
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
+ // Percentage lengths and paths depend on the canvas size, not just SVG props.
+ clearChildCache();
this.invalidate();
}
diff --git a/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java b/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java
new file mode 100644
--- /dev/null
+++ b/node_modules/react-native-svg/android/src/androidTest/java/com/horcrux/svg/SvgResizeTest.java
@@ -0,0 +1,90 @@
+package com.horcrux.svg;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertTrue;
+
+import android.graphics.Bitmap;
+import android.graphics.Canvas;
+import android.graphics.Color;
+import android.view.View;
+import androidx.test.ext.junit.runners.AndroidJUnit4;
+import androidx.test.platform.app.InstrumentationRegistry;
+import com.facebook.react.bridge.BridgeReactContext;
+import com.facebook.react.bridge.DynamicFromObject;
+import com.facebook.react.bridge.JavaOnlyArray;
+import com.facebook.react.bridge.JavaOnlyMap;
+import com.facebook.react.soloader.OpenSourceMergedSoMapping;
+import com.facebook.react.uimanager.DisplayMetricsHolder;
+import com.facebook.soloader.SoLoader;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+
+@RunWith(AndroidJUnit4.class)
+public class SvgResizeTest {
+ @Test
+ public void percentageFadeFollowsCanvasGrowthAndShrinkage() {
+ InstrumentationRegistry.getInstrumentation().runOnMainSync(() -> {
+ android.content.Context app = InstrumentationRegistry.getInstrumentation().getTargetContext();
+ DisplayMetricsHolder.initDisplayMetricsIfNotInitialized(app);
+ try {
+ SoLoader.init(app, OpenSourceMergedSoMapping.INSTANCE);
+ } catch (java.io.IOException exception) {
+ throw new RuntimeException(exception);
+ }
+ BridgeReactContext context = new DrawingContext(app);
+ SvgView svg = new SvgView(context);
+ LinearGradientView gradient = new LinearGradientView(context);
+ gradient.setName("fade");
+ gradient.setX1(new DynamicFromObject("0%"));
+ gradient.setY1(new DynamicFromObject("0%"));
+ gradient.setX2(new DynamicFromObject("0%"));
+ gradient.setY2(new DynamicFromObject("100%"));
+ gradient.setGradientUnits(0);
+ gradient.setGradient(JavaOnlyArray.of(0.0, Color.RED, 1.0, Color.TRANSPARENT));
+ svg.addView(gradient);
+ // Exercise recursive invalidation as well as the direct child case.
+ GroupView group = new GroupView(context);
+ RectView rectangle = new RectView(context);
+ rectangle.setX(new DynamicFromObject(0.0));
+ rectangle.setY(new DynamicFromObject(0.0));
+ rectangle.setWidth(new DynamicFromObject("100%"));
+ rectangle.setHeight(new DynamicFromObject("100%"));
+ rectangle.setFill(JavaOnlyMap.of("type", 1, "brushRef", "fade"));
+ group.addView(rectangle);
+ svg.addView(group);
+
+ assertFade(svg, 80, 60);
+ assertFade(svg, 160, 120);
+ assertFade(svg, 60, 40);
+ assertFade(svg, 160, 120);
+ });
+ }
+
+ // Pixel rendering needs a React context but does not send layout events to JavaScript.
+ private static final class DrawingContext extends BridgeReactContext
+ implements com.facebook.react.uimanager.events.EventDispatcherProvider {
+ DrawingContext(android.content.Context context) { super(context); }
+
+ @Override
+ public com.facebook.react.uimanager.events.EventDispatcher getEventDispatcher() { return null; }
+ }
+
+ private static void assertFade(SvgView svg, int width, int height) {
+ svg.measure(View.MeasureSpec.makeMeasureSpec(width, View.MeasureSpec.EXACTLY),
+ View.MeasureSpec.makeMeasureSpec(height, View.MeasureSpec.EXACTLY));
+ svg.layout(0, 0, width, height);
+ Bitmap image = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ svg.draw(new Canvas(image));
+ try {
+ int middle = Color.alpha(image.getPixel(width / 2, height / 2));
+ int right = Color.alpha(image.getPixel(width - 2, height / 2));
+ assertTrue("Gradient midpoint must follow canvas height at " + width + "x" + height
+ + "; alpha=" + middle, middle >= 120 && middle <= 135);
+ assertEquals("Fade must reach the right edge at " + width + "x" + height, middle, right);
+ assertTrue("Fade must end at the new bottom edge",
+ Color.alpha(image.getPixel(width / 2, height - 2)) < 20);
+ } finally {
+ image.recycle();
+ }
+ }
+}
@@ -705,3 +705,59 @@ index a9c1804..f5a3c20 100644
return Insets(
this._insets.top / density,
diff --git a/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp b/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp
index 01026dc..3786ec4 100644
--- a/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp
+++ b/node_modules/react-native-unistyles/cxx/shadowTree/ShadowTreeManager.cpp
@@ -17,13 +17,16 @@ void shadow::ShadowTreeManager::updateShadowTree(jsi::Runtime& rt) {
}
#if REACT_NATIVE_VERSION_MINOR >= 81
- std::unordered_map<Tag, folly::dynamic> tagToProps;
+ // Paseo backport of https://github.com/jpudysz/react-native-unistyles/pull/1357.
+ // UIManager::updateShadowTree commits a root cloned before the commit, so when a
+ // window-size commit (rotation, keyboard) lands in between, the retried Unistyles
+ // commit puts the previous layout constraints back and the app stays laid out for
+ // the old size. Commit here instead and clone from the root the commit hands us.
+ std::unordered_set<SurfaceId> affectedSurfaces;
for (const auto& [family, props] : updates) {
auto safeProps = props.isObject() ? props : folly::dynamic::object();
- tagToProps.insert({family->getTag(), safeProps});
-
auto* mutableFamily = const_cast<ShadowNodeFamily*>(family);
if (mutableFamily->nativeProps_DEPRECATED && mutableFamily->nativeProps_DEPRECATED->isObject()) {
@@ -31,9 +34,30 @@ void shadow::ShadowTreeManager::updateShadowTree(jsi::Runtime& rt) {
} else {
mutableFamily->nativeProps_DEPRECATED = std::make_unique<folly::dynamic>(safeProps);
}
+
+ affectedSurfaces.insert(family->getSurfaceId());
}
- UIManagerBinding::getBinding(rt)->getUIManager().updateShadowTree(std::move(tagToProps));
+ const auto& shadowTreeRegistry = UIManagerBinding::getBinding(rt)->getUIManager().getShadowTreeRegistry();
+
+ shadowTreeRegistry.enumerate([&updates, &affectedSurfaces](const ShadowTree& shadowTree, bool&){
+ if (!affectedSurfaces.contains(shadowTree.getSurfaceId())) {
+ return;
+ }
+
+ auto transaction = [&updates](const RootShadowNode& oldRootShadowNode) -> RootShadowNode::Unshared {
+ auto affectedNodes = shadow::ShadowTreeManager::findAffectedNodes(oldRootShadowNode, updates);
+
+ return std::static_pointer_cast<RootShadowNode>(shadow::ShadowTreeManager::cloneShadowTree(
+ oldRootShadowNode,
+ updates,
+ affectedNodes
+ ));
+ };
+
+ // mountSynchronously: this is an update from C++, not React
+ shadowTree.commit(transaction, {false, true});
+ });
#else
const auto& shadowTreeRegistry = UIManagerBinding::getBinding(rt)->getUIManager().getShadowTreeRegistry();
+5
View File
@@ -28,6 +28,9 @@ const patchedPackages = [
// withUnistyles: also keep the uniProps filter until upstream consumes its control prop.
// Android configuration: backport 3.5.3; remove the two NativePlatform hunks at >= 3.5.3.
// https://github.com/jpudysz/react-native-unistyles/compare/v3.5.2...v3.5.3
// Layout constraints: backport of https://github.com/jpudysz/react-native-unistyles/pull/1357
// (commit from the root the commit hands us, so a concurrent window-size commit is kept).
// Remove the ShadowTreeManager hunk when the installed release includes that PR.
{
nodeModulesPath: "node_modules/react-native-unistyles",
patchPrefix: "react-native-unistyles+",
@@ -59,6 +62,8 @@ const patchedPackages = [
nodeModulesPath: "node_modules/@gorhom/bottom-sheet",
patchPrefix: "@gorhom+bottom-sheet+",
},
// Android resize: retain until upstream clears descendant geometry when the SVG canvas resizes.
// The native pixel regression is included in the patch; preserve the separate transform hunks.
{
nodeModulesPath: "packages/app/node_modules/react-native-svg",
patchPrefix: "react-native-svg+",