@blario/react-native 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -3
- package/dist/BlarProvider.d.ts +2 -0
- package/dist/actions/scrolling.d.ts +3 -1
- package/dist/actions/select.d.ts +16 -0
- package/dist/actions/text-input.d.ts +1 -0
- package/dist/actions/types.d.ts +3 -0
- package/dist/agent/compose.d.ts +1 -0
- package/dist/agent-mode/keyboard-guard.d.ts +13 -0
- package/dist/backend/app-info.d.ts +1 -0
- package/dist/fiber/text.d.ts +1 -0
- package/dist/snapshot/capture.d.ts +1 -0
- package/dist/snapshot/view.d.ts +3 -0
- package/dist/snapshot/visibility.d.ts +1 -1
- package/dist/snapshot/wrappers.d.ts +0 -2
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
- package/src/BlarProvider.tsx +13 -0
- package/src/actions/click.ts +2 -1
- package/src/actions/executor.ts +8 -2
- package/src/actions/landing.ts +2 -2
- package/src/actions/parse.ts +2 -0
- package/src/actions/scrolling.ts +3 -20
- package/src/actions/select.ts +176 -0
- package/src/actions/text-input.ts +30 -1
- package/src/actions/types.ts +1 -0
- package/src/agent/compose.ts +2 -0
- package/src/agent/controller.ts +3 -1
- package/src/agent-mode/keyboard-guard.ts +33 -0
- package/src/backend/app-info.ts +3 -0
- package/src/chat/ChatSheet.tsx +29 -9
- package/src/chat/ChatView.tsx +1 -1
- package/src/chat/widget-bundle.generated.ts +2 -2
- package/src/fiber/text.ts +2 -0
- package/src/snapshot/capture.ts +9 -6
- package/src/snapshot/collect.ts +1 -2
- package/src/snapshot/render.ts +4 -2
- package/src/snapshot/view.ts +34 -0
- package/src/snapshot/visibility.ts +11 -10
- package/src/snapshot/wrappers.ts +0 -13
- package/src/version.ts +1 -1
package/README.md
CHANGED
|
@@ -63,6 +63,7 @@ export default function App() {
|
|
|
63
63
|
Screenshots use `react-native-view-shot` when it is installed, otherwise `@shopify/react-native-skia`
|
|
64
64
|
(`makeImageFromView`) on iOS. Android requires view-shot: Skia's Android capture can raise a fatal native error
|
|
65
65
|
when a view unmounts during capture. With no supported capture module, a screenshot answers `sdk_error`.
|
|
66
|
+
Android on the New Architecture needs view-shot 4 or later: 3.x fails every capture there, while actions keep working.
|
|
66
67
|
Each module is resolved once when the SDK loads:
|
|
67
68
|
|
|
68
69
|
- view-shot leaves a host `Modal` out (it is a separate window), so the response carries `partial: true` and
|
|
@@ -230,7 +231,7 @@ A run looks and behaves like the web widget's agent mode (`web-sdk/src/managers/
|
|
|
230
231
|
installed (both optional peers); without one this is a no-op, reported once with `debug`.
|
|
231
232
|
|
|
232
233
|
Known differences from the web: the pending dim covers the whole screen (the web dims only the chat window); the
|
|
233
|
-
|
|
234
|
+
keyboard stays closed during a run, unless the chat is open; `session_closed` ends the run (the web only
|
|
234
235
|
tears it down). Visual effects (dot grid, glow, slime) come in a later milestone.
|
|
235
236
|
|
|
236
237
|
### Test nudges without a real delivery
|
|
@@ -284,6 +285,16 @@ npx expo start --clear # Metro; the simulator reaches 127.0.
|
|
|
284
285
|
xcrun simctl launch "iPhone 17 iOS 26" io.blar.example
|
|
285
286
|
```
|
|
286
287
|
|
|
288
|
+
### On an Android emulator
|
|
289
|
+
|
|
290
|
+
```bash
|
|
291
|
+
npx expo prebuild -p android --no-install # it rewrites package.json's scripts: revert that
|
|
292
|
+
# newArchEnabled=true in android/gradle.properties, and sdk.dir in android/local.properties
|
|
293
|
+
(cd android && ./gradlew app:assembleDebug)
|
|
294
|
+
adb install -r android/app/build/outputs/apk/debug/app-debug.apk
|
|
295
|
+
adb reverse tcp:8081 tcp:8081 && adb reverse tcp:8790 tcp:8790
|
|
296
|
+
```
|
|
297
|
+
|
|
287
298
|
`.maestro/` holds [Maestro](https://maestro.dev) flows that tap through the app, chat WebView included
|
|
288
299
|
(`brew install mobile-dev-inc/tap/maestro`, needs Java 17): `maestro test .maestro/chat-keyboard.yaml`.
|
|
289
300
|
|
|
@@ -315,6 +326,7 @@ Wire envelope (mirrors the backend): `agent_command` / `request_dom_state` / `re
|
|
|
315
326
|
| `click` | | `btn-N` (a switch toggles, an input is focused, a wheel picker's row scrolls to the center) |
|
|
316
327
|
| `type` | `value`, `clear_first` (default true) | `inp-N` text input |
|
|
317
328
|
| `check` | `checked` (omit to toggle) | `inp-N` switch |
|
|
329
|
+
| `select` | `value` | `scr-N` or one of its rows: centers the row with that text, in one list at a time |
|
|
318
330
|
| `press_key` | `key` (`Enter` only) | `inp-N`, or the last input used |
|
|
319
331
|
| `scroll` | `direction` up/down/left/right, `amount` px | `scr-N` |
|
|
320
332
|
| `scroll_to` | | any element, uses its nearest scroll container |
|
|
@@ -322,8 +334,11 @@ Wire envelope (mirrors the backend): `agent_command` / `request_dom_state` / `re
|
|
|
322
334
|
| `highlight` | `duration_ms` (default 3000) | any element |
|
|
323
335
|
|
|
324
336
|
`click`, `type`, `check` and `highlight` scroll the target into view first. Elements that sit beyond their scroll
|
|
325
|
-
viewport are listed with `offscreen=above|below|left|right
|
|
326
|
-
list
|
|
337
|
+
viewport are listed with `offscreen=above|below|left|right`, however many there are. The page text also includes what a
|
|
338
|
+
list holds off screen in any direction; only blocks parked beyond the window with no list behind them are left out. A wheel picker is a snapping
|
|
339
|
+
list (`snapToInterval` or `snapToOffsets`) of plain text rows: each row is a `btn-N`, and all of them stay listed. Lists
|
|
340
|
+
carry `list=<name>` from their own or their wrapper's `accessibilityLabel` or `testID`, and so do those rows. `select`
|
|
341
|
+
needs no snap props: it reaches any list, so a wheel that snaps in JS works through it.
|
|
327
342
|
|
|
328
343
|
## Develop
|
|
329
344
|
|
package/dist/BlarProvider.d.ts
CHANGED
|
@@ -42,6 +42,7 @@ export declare class BlarProvider extends Component<BlarProviderProps, BlarProvi
|
|
|
42
42
|
private chatControls;
|
|
43
43
|
private shownNudge;
|
|
44
44
|
private readonly releases;
|
|
45
|
+
private readonly keyboardGuard;
|
|
45
46
|
private stackingCheckQueued;
|
|
46
47
|
private loggedPhase;
|
|
47
48
|
private readonly stacking;
|
|
@@ -62,6 +63,7 @@ export declare class BlarProvider extends Component<BlarProviderProps, BlarProvi
|
|
|
62
63
|
private createTransport;
|
|
63
64
|
private createChat;
|
|
64
65
|
private attachBackend;
|
|
66
|
+
private syncKeyboardGuard;
|
|
65
67
|
private queueStackingCheck;
|
|
66
68
|
private keepLayerOnTop;
|
|
67
69
|
private countHostModals;
|
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
import { type Band } from '../host/rect.ts';
|
|
2
|
+
import { type ScrollGeometry } from '../snapshot/scroll.ts';
|
|
2
3
|
import type { ResolvedTarget } from './target.ts';
|
|
3
4
|
import type { AgentAction, HandlerResult } from './types.ts';
|
|
4
5
|
type ScrollAction = Extract<AgentAction, {
|
|
5
6
|
type: 'scroll';
|
|
6
7
|
}>;
|
|
8
|
+
export declare function applyOffset(target: ResolvedTarget, geometry: ScrollGeometry, offset: number, animated: boolean): HandlerResult;
|
|
7
9
|
export declare function scrollContainer(target: ResolvedTarget, action: ScrollAction): Promise<HandlerResult>;
|
|
10
|
+
export declare function nearestScrollAncestor(target: ResolvedTarget): ResolvedTarget['fiber'] | null;
|
|
8
11
|
export declare function isInWheel(target: ResolvedTarget): boolean;
|
|
9
|
-
export declare function selectWheelOption(target: ResolvedTarget): Promise<HandlerResult>;
|
|
10
12
|
export declare function scrollIntoView(target: ResolvedTarget, options?: {
|
|
11
13
|
animated: boolean;
|
|
12
14
|
}): Promise<HandlerResult>;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Fiber } from '../fiber/types.ts';
|
|
2
|
+
import type { ActionServices } from './services.ts';
|
|
3
|
+
import type { ResolvedTarget } from './target.ts';
|
|
4
|
+
import type { HandlerResult } from './types.ts';
|
|
5
|
+
interface ListRow {
|
|
6
|
+
host: Fiber;
|
|
7
|
+
text: string;
|
|
8
|
+
}
|
|
9
|
+
export declare function centerInList(target: ResolvedTarget, list: Fiber, row: Fiber): Promise<HandlerResult>;
|
|
10
|
+
export declare function scrollListTo(target: ResolvedTarget, list: Fiber, offset: number): Promise<HandlerResult>;
|
|
11
|
+
export declare function selectWheelOption(target: ResolvedTarget): Promise<HandlerResult>;
|
|
12
|
+
export declare function rowsOf(list: Fiber): ListRow[];
|
|
13
|
+
export declare function matchesRow(text: string, value: string): boolean;
|
|
14
|
+
export declare function selectValue(target: ResolvedTarget, value: string, services: ActionServices, isCancelled?: () => boolean): Promise<HandlerResult>;
|
|
15
|
+
export declare function walk(from: number, to: number, step: number): number[];
|
|
16
|
+
export {};
|
|
@@ -4,6 +4,7 @@ import type { AgentAction, HandlerResult } from './types.ts';
|
|
|
4
4
|
type TypeAction = Extract<AgentAction, {
|
|
5
5
|
type: 'type';
|
|
6
6
|
}>;
|
|
7
|
+
export declare function restoreKeyboards(): void;
|
|
7
8
|
export declare function typeText(target: ResolvedTarget, action: TypeAction, services: ActionServices): HandlerResult;
|
|
8
9
|
export declare function submitInput(target: ResolvedTarget): HandlerResult;
|
|
9
10
|
export {};
|
package/dist/actions/types.d.ts
CHANGED
package/dist/agent/compose.d.ts
CHANGED
|
@@ -11,6 +11,7 @@ export interface AgentEnvironment {
|
|
|
11
11
|
transport: AgentTransport;
|
|
12
12
|
getAnchorFiber: () => Fiber | null;
|
|
13
13
|
getViewport: () => Size;
|
|
14
|
+
getWindow: () => Size;
|
|
14
15
|
getNavigationRef: () => NavigationRefLike | null;
|
|
15
16
|
getCaptureTarget: () => unknown;
|
|
16
17
|
reading: ReadingPort;
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export interface KeyboardPort {
|
|
2
|
+
addListener: (event: 'keyboardWillShow' | 'keyboardDidShow', listener: () => void) => {
|
|
3
|
+
remove: () => void;
|
|
4
|
+
};
|
|
5
|
+
dismiss: () => void;
|
|
6
|
+
}
|
|
7
|
+
export declare class KeyboardGuard {
|
|
8
|
+
private readonly keyboard;
|
|
9
|
+
private readonly onRelease;
|
|
10
|
+
private subscriptions;
|
|
11
|
+
constructor(keyboard: KeyboardPort, onRelease?: () => void);
|
|
12
|
+
setActive(active: boolean): void;
|
|
13
|
+
}
|
package/dist/fiber/text.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Fiber } from './types.ts';
|
|
2
|
+
export declare const TEXT_HOST_TYPE = "RCTText";
|
|
2
3
|
export declare function normalizeText(value: string): string;
|
|
3
4
|
export declare function collectText(fiber: Fiber, limit: number): string;
|
|
4
5
|
export declare function precedingText(ancestors: Fiber[], fiber: Fiber, limit: number): string;
|
package/dist/snapshot/view.d.ts
CHANGED
|
@@ -17,8 +17,10 @@ export interface ElementView {
|
|
|
17
17
|
selected: boolean | null;
|
|
18
18
|
scroll: string | null;
|
|
19
19
|
offscreen: Offscreen | null;
|
|
20
|
+
list: string | null;
|
|
20
21
|
rect: Rect;
|
|
21
22
|
}
|
|
23
|
+
export declare function listNameOf(list: Fiber, ancestors: Fiber[]): string | null;
|
|
22
24
|
export interface ViewSource {
|
|
23
25
|
kind: ElementKind;
|
|
24
26
|
fiber: Fiber;
|
|
@@ -26,5 +28,6 @@ export interface ViewSource {
|
|
|
26
28
|
rect: Rect;
|
|
27
29
|
scroll: string | null;
|
|
28
30
|
offscreen?: Offscreen | null;
|
|
31
|
+
wheelOption?: boolean;
|
|
29
32
|
}
|
|
30
33
|
export declare function buildView(source: ViewSource): ElementView;
|
|
@@ -9,9 +9,9 @@ export type Visibility = {
|
|
|
9
9
|
} | {
|
|
10
10
|
kind: 'offscreen';
|
|
11
11
|
direction: Offscreen;
|
|
12
|
-
distance: number;
|
|
13
12
|
} | {
|
|
14
13
|
kind: 'hidden';
|
|
15
14
|
};
|
|
16
15
|
export declare function classifyVisibility(rect: Rect, window: Size, containers: Fiber[], geometries: ScrollGeometries): Visibility;
|
|
16
|
+
export declare function markOffscreenText(visibility: Visibility, text: string): string;
|
|
17
17
|
export declare function keepInTextTree(visibility: Visibility, rect: Rect, window: Size, viewportOnly: boolean): boolean;
|
|
@@ -5,7 +5,5 @@ export interface MeasuredCandidate {
|
|
|
5
5
|
candidate: Candidate;
|
|
6
6
|
rect: Rect;
|
|
7
7
|
offscreen: Offscreen | null;
|
|
8
|
-
distance: number;
|
|
9
8
|
}
|
|
10
9
|
export declare function dropWrapperDuplicates(measured: MeasuredCandidate[]): MeasuredCandidate[];
|
|
11
|
-
export declare function limitOffscreen(measured: MeasuredCandidate[]): MeasuredCandidate[];
|
package/dist/version.d.ts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const SDK_VERSION = "0.1.
|
|
1
|
+
export declare const SDK_VERSION = "0.1.6";
|
package/package.json
CHANGED
package/src/BlarProvider.tsx
CHANGED
|
@@ -13,6 +13,8 @@ import type { AgentModePhase } from './agent-mode/state.ts';
|
|
|
13
13
|
import { KEEP_AWAKE_ADAPTER, KeepAwake } from './agent-mode/keep-awake.ts';
|
|
14
14
|
import { LayerStacking } from './agent-mode/layer-stacking.ts';
|
|
15
15
|
import { AgentLayer } from './agent-mode/ui/AgentLayer.tsx';
|
|
16
|
+
import { restoreKeyboards } from './actions/text-input.ts';
|
|
17
|
+
import { KeyboardGuard } from './agent-mode/keyboard-guard.ts';
|
|
16
18
|
import { AgentLayerHost } from './agent-mode/ui/AgentLayerHost.tsx';
|
|
17
19
|
import { BackendTransport, type WidgetProof } from './backend/backend-transport.ts';
|
|
18
20
|
import type { BlarIdentity } from './backend/identity.ts';
|
|
@@ -104,6 +106,8 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
|
|
|
104
106
|
|
|
105
107
|
private readonly releases: Array<() => void> = [];
|
|
106
108
|
|
|
109
|
+
private readonly keyboardGuard = new KeyboardGuard(Keyboard, restoreKeyboards);
|
|
110
|
+
|
|
107
111
|
private stackingCheckQueued = false;
|
|
108
112
|
|
|
109
113
|
private loggedPhase: AgentModePhase = 'idle';
|
|
@@ -163,6 +167,7 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
|
|
|
163
167
|
transport,
|
|
164
168
|
getAnchorFiber: () => (this as WithReactInternals)._reactInternals ?? null,
|
|
165
169
|
getViewport: () => visibleViewport(Dimensions.get('window'), Keyboard.metrics()?.height ?? 0),
|
|
170
|
+
getWindow: () => Dimensions.get('window'),
|
|
166
171
|
getNavigationRef: () => this.props.navigationRef ?? null,
|
|
167
172
|
getCaptureTarget: () => this.contentRef.current,
|
|
168
173
|
reading: this.spotlight,
|
|
@@ -316,8 +321,10 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
|
|
|
316
321
|
this.debug(`agent mode ${snapshot.phase}`);
|
|
317
322
|
}
|
|
318
323
|
this.setState({ agentMode: snapshot });
|
|
324
|
+
this.syncKeyboardGuard();
|
|
319
325
|
}),
|
|
320
326
|
);
|
|
327
|
+
this.releases.push(() => this.keyboardGuard.setActive(false));
|
|
321
328
|
return agentMode;
|
|
322
329
|
}
|
|
323
330
|
|
|
@@ -373,6 +380,7 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
|
|
|
373
380
|
coordinator.subscribe((chat) => {
|
|
374
381
|
this.noteNudge(chat);
|
|
375
382
|
this.setState({ chat });
|
|
383
|
+
this.syncKeyboardGuard();
|
|
376
384
|
}),
|
|
377
385
|
);
|
|
378
386
|
this.releases.push(backend.onFrame((frame) => coordinator.handleFrame(frame)));
|
|
@@ -395,6 +403,11 @@ export class BlarProvider extends Component<BlarProviderProps, BlarProviderState
|
|
|
395
403
|
Blar.attach(backend, (options) => controller.readMessageMetadata({ ...options, tabId: backend.getTabId() }));
|
|
396
404
|
}
|
|
397
405
|
|
|
406
|
+
private syncKeyboardGuard(): void {
|
|
407
|
+
const running = this.agentMode?.getSnapshot().phase === 'active';
|
|
408
|
+
this.keyboardGuard.setActive(running && this.chatControls?.isOpen() !== true);
|
|
409
|
+
}
|
|
410
|
+
|
|
398
411
|
private queueStackingCheck(): void {
|
|
399
412
|
if (this.stackingCheckQueued || (this.agentMode?.getSnapshot().phase ?? 'idle') === 'idle') {
|
|
400
413
|
return;
|
package/src/actions/click.ts
CHANGED
|
@@ -6,7 +6,8 @@ import { buildPressEvent } from './events.ts';
|
|
|
6
6
|
import { checkInteractable } from './interaction-guards.ts';
|
|
7
7
|
import { failed, failedFromException, succeeded } from './result.ts';
|
|
8
8
|
import type { ActionServices } from './services.ts';
|
|
9
|
-
import { isInWheel
|
|
9
|
+
import { isInWheel } from './scrolling.ts';
|
|
10
|
+
import { selectWheelOption } from './select.ts';
|
|
10
11
|
import type { ResolvedTarget } from './target.ts';
|
|
11
12
|
import type { HandlerResult } from './types.ts';
|
|
12
13
|
import { toggleSwitch } from './switch.ts';
|
package/src/actions/executor.ts
CHANGED
|
@@ -5,6 +5,7 @@ import { highlight } from './highlight.ts';
|
|
|
5
5
|
import { tapsTarget, waitForLanding, waitsForLanding } from './landing.ts';
|
|
6
6
|
import { failed, failedFromException } from './result.ts';
|
|
7
7
|
import { scrollContainer, scrollIntoView } from './scrolling.ts';
|
|
8
|
+
import { selectValue } from './select.ts';
|
|
8
9
|
import { SYSTEM_CLOCK } from './settle.ts';
|
|
9
10
|
import type { ActionServices } from './services.ts';
|
|
10
11
|
import { waitForSettle } from './settle.ts';
|
|
@@ -40,6 +41,9 @@ export class ActionExecutor {
|
|
|
40
41
|
if (command.action.type === 'navigate') {
|
|
41
42
|
return { ok: true, target: null };
|
|
42
43
|
}
|
|
44
|
+
if (command.action.type === 'press_key' && command.action.key !== 'Enter') {
|
|
45
|
+
return { ok: false, outcome: outcomeOf(failed('unsupported_action', `key ${command.action.key} is not supported: only Enter is. To move a list use scroll or select on its scr-N`)) };
|
|
46
|
+
}
|
|
43
47
|
const target = this.resolveTarget(command);
|
|
44
48
|
if (target === null) {
|
|
45
49
|
return { ok: false, outcome: outcomeOf(this.missingTarget(command)) };
|
|
@@ -78,7 +82,7 @@ export class ActionExecutor {
|
|
|
78
82
|
if (options.isCancelled()) {
|
|
79
83
|
return failed('user_cancelled', CANCELLED_MESSAGE);
|
|
80
84
|
}
|
|
81
|
-
return await this.dispatch(command, target);
|
|
85
|
+
return await this.dispatch(command, target, options);
|
|
82
86
|
} catch (error) {
|
|
83
87
|
return failedFromException(error);
|
|
84
88
|
}
|
|
@@ -123,7 +127,7 @@ export class ActionExecutor {
|
|
|
123
127
|
return failed('element_not_found', reason);
|
|
124
128
|
}
|
|
125
129
|
|
|
126
|
-
private dispatch(command: AgentCommandData, target: ResolvedTarget): Promise<HandlerResult> | HandlerResult {
|
|
130
|
+
private dispatch(command: AgentCommandData, target: ResolvedTarget, options: ExecutionOptions): Promise<HandlerResult> | HandlerResult {
|
|
127
131
|
const { action } = command;
|
|
128
132
|
switch (action.type) {
|
|
129
133
|
case 'click':
|
|
@@ -132,6 +136,8 @@ export class ActionExecutor {
|
|
|
132
136
|
return typeText(target, action, this.services);
|
|
133
137
|
case 'check':
|
|
134
138
|
return toggleSwitch(target, action.checked);
|
|
139
|
+
case 'select':
|
|
140
|
+
return selectValue(target, action.value, this.services, options.isCancelled);
|
|
135
141
|
case 'press_key':
|
|
136
142
|
return action.key === 'Enter' ? submitInput(target) : failed('unsupported_action', `key ${action.key} is not supported`);
|
|
137
143
|
case 'scroll':
|
package/src/actions/landing.ts
CHANGED
|
@@ -9,9 +9,9 @@ export interface CursorPort {
|
|
|
9
9
|
|
|
10
10
|
export const LANDING_MAX_MS = 1800;
|
|
11
11
|
|
|
12
|
-
const TAP_ACTIONS: readonly AgentAction['type'][] = ['click', 'check', 'type'];
|
|
12
|
+
const TAP_ACTIONS: readonly AgentAction['type'][] = ['click', 'check', 'select', 'type'];
|
|
13
13
|
|
|
14
|
-
const LANDING_ACTIONS: readonly AgentAction['type'][] = ['click', 'check'];
|
|
14
|
+
const LANDING_ACTIONS: readonly AgentAction['type'][] = ['click', 'check', 'select'];
|
|
15
15
|
|
|
16
16
|
export function tapsTarget(action: AgentAction): boolean {
|
|
17
17
|
return TAP_ACTIONS.includes(action.type);
|
package/src/actions/parse.ts
CHANGED
|
@@ -32,6 +32,8 @@ function parseAction(raw: unknown): AgentAction | null {
|
|
|
32
32
|
return typeof raw.value === 'string' ? { type: 'type', value: raw.value, clear_first: raw.clear_first !== false } : null;
|
|
33
33
|
case 'check':
|
|
34
34
|
return { type: 'check', checked: typeof raw.checked === 'boolean' ? raw.checked : null };
|
|
35
|
+
case 'select':
|
|
36
|
+
return typeof raw.value === 'string' ? { type: 'select', value: raw.value } : null;
|
|
35
37
|
case 'press_key':
|
|
36
38
|
return typeof raw.key === 'string' ? { type: 'press_key', key: raw.key } : null;
|
|
37
39
|
case 'scroll':
|
package/src/actions/scrolling.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { measureHost } from '../host/measure.ts';
|
|
2
2
|
import { type Band, scrollDeltaIntoBand } from '../host/rect.ts';
|
|
3
3
|
import { scrollInstanceOf } from '../snapshot/classify.ts';
|
|
4
|
-
import { isWheel, maxScrollOffsetOf, measureScrollGeometry,
|
|
4
|
+
import { isWheel, maxScrollOffsetOf, measureScrollGeometry, scrollOffsetOf, type ScrollGeometry, viewportExtentOf } from '../snapshot/scroll.ts';
|
|
5
5
|
import { failed, failedFromException, succeeded } from './result.ts';
|
|
6
6
|
import type { ResolvedTarget } from './target.ts';
|
|
7
7
|
import type { AgentAction, HandlerResult } from './types.ts';
|
|
@@ -16,7 +16,7 @@ function clamp(value: number, minimum: number, maximum: number): number {
|
|
|
16
16
|
return Math.min(Math.max(value, minimum), maximum);
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
function applyOffset(target: ResolvedTarget, geometry: ScrollGeometry, offset: number, animated: boolean): HandlerResult {
|
|
19
|
+
export function applyOffset(target: ResolvedTarget, geometry: ScrollGeometry, offset: number, animated: boolean): HandlerResult {
|
|
20
20
|
const instance = scrollInstanceOf(target.fiber);
|
|
21
21
|
if (instance === null) {
|
|
22
22
|
return failed('element_not_interactable', 'the scroll container has no scrollTo');
|
|
@@ -51,7 +51,7 @@ export async function scrollContainer(target: ResolvedTarget, action: ScrollActi
|
|
|
51
51
|
return applyOffset(target, geometry, next, true);
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
-
function nearestScrollAncestor(target: ResolvedTarget): ResolvedTarget['fiber'] | null {
|
|
54
|
+
export function nearestScrollAncestor(target: ResolvedTarget): ResolvedTarget['fiber'] | null {
|
|
55
55
|
for (let index = target.ancestors.length - 1; index >= 0; index -= 1) {
|
|
56
56
|
const ancestor = target.ancestors[index];
|
|
57
57
|
if (ancestor !== undefined && scrollInstanceOf(ancestor) !== null) {
|
|
@@ -66,23 +66,6 @@ export function isInWheel(target: ResolvedTarget): boolean {
|
|
|
66
66
|
return container !== null && isWheel(container);
|
|
67
67
|
}
|
|
68
68
|
|
|
69
|
-
// The animated scroll ends in onMomentumScrollEnd, the event a wheel commits its value on, as a user's swipe does.
|
|
70
|
-
export async function selectWheelOption(target: ResolvedTarget): Promise<HandlerResult> {
|
|
71
|
-
const container = nearestScrollAncestor(target);
|
|
72
|
-
if (container === null || target.host === null) {
|
|
73
|
-
return failed('element_not_interactable', 'the option is not inside a wheel');
|
|
74
|
-
}
|
|
75
|
-
const [geometry, rect] = await Promise.all([measureScrollGeometry(container), measureHost(target.host)]);
|
|
76
|
-
if (geometry === null || rect === null) {
|
|
77
|
-
return failed('element_not_found', 'the option or its wheel could not be measured');
|
|
78
|
-
}
|
|
79
|
-
const next = offsetCentering(rect, geometry);
|
|
80
|
-
if (Math.abs(next - scrollOffsetOf(geometry)) < MOVEMENT_TOLERANCE) {
|
|
81
|
-
return succeeded('the option is already selected');
|
|
82
|
-
}
|
|
83
|
-
return applyOffset({ ...target, fiber: container }, geometry, next, true);
|
|
84
|
-
}
|
|
85
|
-
|
|
86
69
|
function offsetDeltaToReveal(start: number, end: number, viewportStart: number, viewportEnd: number): number {
|
|
87
70
|
const startShortfall = start - (viewportStart + VISIBLE_MARGIN);
|
|
88
71
|
if (startShortfall < 0) {
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { Platform } from 'react-native';
|
|
2
|
+
|
|
3
|
+
import { isFunction } from '../fiber/narrow.ts';
|
|
4
|
+
import { childrenOf, isHostComponent, propsOf } from '../fiber/reader.ts';
|
|
5
|
+
import { collectText, TEXT_HOST_TYPE } from '../fiber/text.ts';
|
|
6
|
+
import type { Fiber } from '../fiber/types.ts';
|
|
7
|
+
import { measureHost } from '../host/measure.ts';
|
|
8
|
+
import { scrollInstanceOf } from '../snapshot/classify.ts';
|
|
9
|
+
import { maxScrollOffsetOf, measureScrollGeometry, offsetCentering, type ScrollGeometry, scrollOffsetOf, viewportExtentOf } from '../snapshot/scroll.ts';
|
|
10
|
+
import { runBatched } from './batched.ts';
|
|
11
|
+
import { failed, succeeded } from './result.ts';
|
|
12
|
+
import { applyOffset, isInWheel, nearestScrollAncestor } from './scrolling.ts';
|
|
13
|
+
import { SYSTEM_CLOCK } from './settle.ts';
|
|
14
|
+
import type { ActionServices } from './services.ts';
|
|
15
|
+
import type { ResolvedTarget } from './target.ts';
|
|
16
|
+
import type { HandlerResult } from './types.ts';
|
|
17
|
+
|
|
18
|
+
const ROW_TEXT_LIMIT = 80;
|
|
19
|
+
const MOUNT_WAIT_MS = 600;
|
|
20
|
+
const WALK_STEP_FRACTION = 3;
|
|
21
|
+
const WALK_SETTLE_MS = 120;
|
|
22
|
+
const MOUNT_POLL_MS = 150;
|
|
23
|
+
const LISTED_OPTIONS = 30;
|
|
24
|
+
const CANCELLED_MESSAGE = 'Cancelled by the user before the action ran';
|
|
25
|
+
const MOVEMENT_TOLERANCE = 1;
|
|
26
|
+
const SCROLL_ANIMATION_MS = 400;
|
|
27
|
+
|
|
28
|
+
interface ListRow {
|
|
29
|
+
host: Fiber;
|
|
30
|
+
text: string;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// iOS ends an animated scrollTo in onMomentumScrollEnd, the event a wheel commits its value on, as a user's swipe does.
|
|
34
|
+
// Android only fires it after a fling, so the wheel is told here.
|
|
35
|
+
function commitScrollEnd(list: Fiber, geometry: ScrollGeometry, offset: number): void {
|
|
36
|
+
const onMomentumScrollEnd = propsOf(list).onMomentumScrollEnd;
|
|
37
|
+
if (Platform.OS !== 'android' || !isFunction(onMomentumScrollEnd)) {
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
const { viewport, content, horizontal } = geometry;
|
|
41
|
+
runBatched(() =>
|
|
42
|
+
onMomentumScrollEnd({
|
|
43
|
+
persist: () => undefined,
|
|
44
|
+
nativeEvent: {
|
|
45
|
+
contentInset: { top: 0, left: 0, bottom: 0, right: 0 },
|
|
46
|
+
contentOffset: horizontal ? { x: offset, y: 0 } : { x: 0, y: offset },
|
|
47
|
+
contentSize: { width: content.width, height: content.height },
|
|
48
|
+
layoutMeasurement: { width: viewport.width, height: viewport.height },
|
|
49
|
+
},
|
|
50
|
+
}),
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export async function centerInList(target: ResolvedTarget, list: Fiber, row: Fiber): Promise<HandlerResult> {
|
|
55
|
+
const [geometry, rect] = await Promise.all([measureScrollGeometry(list), measureHost(row)]);
|
|
56
|
+
if (geometry === null || rect === null) {
|
|
57
|
+
return failed('element_not_found', 'the option or its list could not be measured');
|
|
58
|
+
}
|
|
59
|
+
const next = offsetCentering(rect, geometry);
|
|
60
|
+
if (Math.abs(next - scrollOffsetOf(geometry)) < MOVEMENT_TOLERANCE) {
|
|
61
|
+
commitScrollEnd(list, geometry, next);
|
|
62
|
+
return succeeded('the option is already selected');
|
|
63
|
+
}
|
|
64
|
+
const moved = applyOffset({ ...target, fiber: list }, geometry, next, true);
|
|
65
|
+
if (moved.ok) {
|
|
66
|
+
await SYSTEM_CLOCK.sleep(SCROLL_ANIMATION_MS);
|
|
67
|
+
commitScrollEnd(list, geometry, next);
|
|
68
|
+
}
|
|
69
|
+
return moved;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export async function scrollListTo(target: ResolvedTarget, list: Fiber, offset: number): Promise<HandlerResult> {
|
|
73
|
+
const geometry = await measureScrollGeometry(list);
|
|
74
|
+
return geometry === null ? failed('element_not_found', 'the list could not be measured') : applyOffset({ ...target, fiber: list }, geometry, offset, false);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export async function selectWheelOption(target: ResolvedTarget): Promise<HandlerResult> {
|
|
78
|
+
const container = nearestScrollAncestor(target);
|
|
79
|
+
if (container === null || target.host === null) {
|
|
80
|
+
return failed('element_not_interactable', 'the option is not inside a wheel');
|
|
81
|
+
}
|
|
82
|
+
return centerInList(target, container, target.host);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function rowsOf(list: Fiber): ListRow[] {
|
|
86
|
+
const rows: ListRow[] = [];
|
|
87
|
+
const visit = (fiber: Fiber): void => {
|
|
88
|
+
if (fiber !== list && scrollInstanceOf(fiber) !== null) {
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
if (isHostComponent(fiber) && fiber.type === TEXT_HOST_TYPE) {
|
|
92
|
+
const text = collectText(fiber, ROW_TEXT_LIMIT);
|
|
93
|
+
if (text.length > 0) {
|
|
94
|
+
rows.push({ host: fiber, text });
|
|
95
|
+
}
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
childrenOf(fiber).forEach(visit);
|
|
99
|
+
};
|
|
100
|
+
visit(list);
|
|
101
|
+
return rows;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function matchesRow(text: string, value: string): boolean {
|
|
105
|
+
const [left, right] = [text.trim().toLowerCase(), value.trim().toLowerCase()];
|
|
106
|
+
return left === right || (/^\d+$/.test(left) && /^\d+$/.test(right) && Number(left) === Number(right));
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Any list counts when the agent names it, not only a snapping one: this is what reaches a wheel that snaps in JS.
|
|
110
|
+
function listOf(target: ResolvedTarget): Fiber | null {
|
|
111
|
+
if (target.element.kind === 'scroll') {
|
|
112
|
+
return target.fiber;
|
|
113
|
+
}
|
|
114
|
+
return isInWheel(target) ? nearestScrollAncestor(target) : null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// A virtualized list mounts its rows shortly after it opens, and a distant row only once scrolling gets near it: wait
|
|
118
|
+
// a moment, then walk the list. A value that isn't there leaves the list where it was.
|
|
119
|
+
export async function selectValue(target: ResolvedTarget, value: string, services: ActionServices, isCancelled: () => boolean = () => false): Promise<HandlerResult> {
|
|
120
|
+
const first = listOf(target);
|
|
121
|
+
if (first === null) {
|
|
122
|
+
return failed('element_not_interactable', 'select works on one open list: click this field to open its picker, then select each value on its list (scr-N)');
|
|
123
|
+
}
|
|
124
|
+
let current = target;
|
|
125
|
+
let list = first;
|
|
126
|
+
const seen = new Set<string>();
|
|
127
|
+
const findRow = (): ListRow | undefined => {
|
|
128
|
+
current = services.resolver.resolveElement(target.element) ?? current;
|
|
129
|
+
list = listOf(current) ?? list;
|
|
130
|
+
const rows = rowsOf(list);
|
|
131
|
+
rows.forEach((row) => seen.add(row.text));
|
|
132
|
+
return rows.find((row) => matchesRow(row.text, value));
|
|
133
|
+
};
|
|
134
|
+
let row = findRow();
|
|
135
|
+
for (let waited = 0; row === undefined && waited < MOUNT_WAIT_MS && !isCancelled(); waited += MOUNT_POLL_MS) {
|
|
136
|
+
await SYSTEM_CLOCK.sleep(MOUNT_POLL_MS);
|
|
137
|
+
row = findRow();
|
|
138
|
+
}
|
|
139
|
+
if (isCancelled()) {
|
|
140
|
+
return failed('user_cancelled', CANCELLED_MESSAGE);
|
|
141
|
+
}
|
|
142
|
+
if (row !== undefined) {
|
|
143
|
+
return centerInList(current, list, row.host);
|
|
144
|
+
}
|
|
145
|
+
const geometry = await measureScrollGeometry(list);
|
|
146
|
+
if (geometry !== null) {
|
|
147
|
+
const start = scrollOffsetOf(geometry);
|
|
148
|
+
const step = Math.max(1, viewportExtentOf(geometry) * WALK_STEP_FRACTION);
|
|
149
|
+
const end = maxScrollOffsetOf(geometry);
|
|
150
|
+
const stops = [...walk(start, end, step), ...walk(start, 0, step)];
|
|
151
|
+
for (const stop of stops) {
|
|
152
|
+
if (isCancelled()) {
|
|
153
|
+
await scrollListTo(current, list, start);
|
|
154
|
+
return failed('user_cancelled', CANCELLED_MESSAGE);
|
|
155
|
+
}
|
|
156
|
+
await scrollListTo(current, list, stop);
|
|
157
|
+
await SYSTEM_CLOCK.sleep(WALK_SETTLE_MS);
|
|
158
|
+
row = findRow();
|
|
159
|
+
if (row !== undefined) {
|
|
160
|
+
return centerInList(current, list, row.host);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
await scrollListTo(current, list, start);
|
|
164
|
+
}
|
|
165
|
+
const options = [...seen].slice(0, LISTED_OPTIONS).join(', ');
|
|
166
|
+
return failed('element_not_found', `"${value}" is not in this list. Options: ${options}`);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function walk(from: number, to: number, step: number): number[] {
|
|
170
|
+
const stops: number[] = [];
|
|
171
|
+
const direction = to >= from ? 1 : -1;
|
|
172
|
+
for (let offset = from + direction * step; direction > 0 ? offset < to : offset > to; offset += direction * step) {
|
|
173
|
+
stops.push(offset);
|
|
174
|
+
}
|
|
175
|
+
return from === to ? stops : [...stops, to];
|
|
176
|
+
}
|
|
@@ -25,6 +25,33 @@ function isControlled(target: ResolvedTarget): boolean {
|
|
|
25
25
|
return typeof target.props.value === 'string';
|
|
26
26
|
}
|
|
27
27
|
|
|
28
|
+
const noKeyboard = new Map<NativeInput, boolean>();
|
|
29
|
+
|
|
30
|
+
interface NativeInput {
|
|
31
|
+
setNativeProps?: (props: { showSoftInputOnFocus: boolean }) => void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Focus without a keyboard: blurring a field to close it would dismiss what depends on its focus, like the suggestions
|
|
35
|
+
// of a search box. The field gets its keyboard back when the run ends.
|
|
36
|
+
function focusWithoutKeyboard(instance: NativeInput & { focus?: () => void }, target: ResolvedTarget): void {
|
|
37
|
+
if (!noKeyboard.has(instance)) {
|
|
38
|
+
noKeyboard.set(instance, target.props.showSoftInputOnFocus !== false);
|
|
39
|
+
instance.setNativeProps?.({ showSoftInputOnFocus: false });
|
|
40
|
+
}
|
|
41
|
+
instance.focus?.();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function restoreKeyboards(): void {
|
|
45
|
+
noKeyboard.forEach((enabled, instance) => {
|
|
46
|
+
try {
|
|
47
|
+
instance.setNativeProps?.({ showSoftInputOnFocus: enabled });
|
|
48
|
+
} catch {
|
|
49
|
+
// The field is gone: nothing left to restore.
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
noKeyboard.clear();
|
|
53
|
+
}
|
|
54
|
+
|
|
28
55
|
export function typeText(target: ResolvedTarget, action: TypeAction, services: ActionServices): HandlerResult {
|
|
29
56
|
if (target.element.kind !== 'text_input') {
|
|
30
57
|
return failed('element_not_interactable', 'type needs a text input');
|
|
@@ -36,7 +63,9 @@ export function typeText(target: ResolvedTarget, action: TypeAction, services: A
|
|
|
36
63
|
const next = action.clear_first ? action.value : currentTextOf(target) + action.value;
|
|
37
64
|
const instance = target.host === null ? null : hostInstanceOf(target.host);
|
|
38
65
|
try {
|
|
39
|
-
instance
|
|
66
|
+
if (instance !== null) {
|
|
67
|
+
focusWithoutKeyboard(instance as NativeInput & { focus?: () => void }, target);
|
|
68
|
+
}
|
|
40
69
|
if (!isControlled(target)) {
|
|
41
70
|
if (Platform.OS === 'android') {
|
|
42
71
|
const tag = findNodeHandle(instance as Parameters<typeof findNodeHandle>[0]);
|
package/src/actions/types.ts
CHANGED
|
@@ -6,6 +6,7 @@ export type AgentAction =
|
|
|
6
6
|
| { type: 'click' }
|
|
7
7
|
| { type: 'type'; value: string; clear_first: boolean }
|
|
8
8
|
| { type: 'check'; checked: boolean | null }
|
|
9
|
+
| { type: 'select'; value: string }
|
|
9
10
|
| { type: 'press_key'; key: string }
|
|
10
11
|
| { type: 'scroll'; direction: ScrollDirectionName; amount: number | null }
|
|
11
12
|
| { type: 'scroll_to' }
|