ink-framework 0.1.0-alpha.10 → 0.1.0-alpha.13

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/DESIGN.md ADDED
@@ -0,0 +1,168 @@
1
+ # Design apps with Ink
2
+
3
+ Build Light Phone III apps from Ink's screens and components. Ink already supplies typography, spacing, padding, colours, navigation, scrolling and keyboard behaviour. Your job is to choose the right page for each task and the right component for each piece of content.
4
+
5
+ Follow the steps in order. Do not write screen code until you have a page map.
6
+
7
+ ## 1. Write a page map
8
+
9
+ List every page before you build. Give each one a route, a single page type from [Page types](#2-choose-a-page-type) and the page it is reached from.
10
+
11
+ ```text
12
+ / Main task Tuner reading. Header: settings icon → /settings
13
+ /settings Settings Toggles, Reference pitch field → /reference
14
+ /reference Choice Reference pitch options. Saves and goes back
15
+ ```
16
+
17
+ Shape the map with these rules:
18
+
19
+ - `/` opens on the app's main task. Do not make it a menu of buttons that lead to the real content. Add setup or welcome pages only when the app cannot work without them, and leave them once setup is complete.
20
+ - Use tabs only for two to four peer sections that users switch between often, such as Today, Locations and Settings. Put tab pages in `app/(tabs)/`.
21
+ - Reach Settings from a header icon or a tab. Reach per-item actions from a `moreHoriz` header icon or a long press, which opens an options page.
22
+ - Keep the tree shallow: section → item → options or choice. If a page needs a fourth level, group the settings differently.
23
+ - When a page has two jobs, it is two pages. Split it.
24
+
25
+ ## 2. Choose a page type
26
+
27
+ Every page is exactly one of these types. Use the listed components and nothing else at the top level of the page.
28
+
29
+ | Page type | Use it for | Build with |
30
+ | --- | --- | --- |
31
+ | Main task | The app's primary reading or tool | `Screen` with the one thing users came for. Use `centered` for short content. Header icons for Settings and secondary pages |
32
+ | Collection | Items users browse or open | `List` of `Row`. `EmptyState` when there are no items. `add` header icon to create |
33
+ | Detail | One item | `Screen` titled with the item name. Content first, `moreHoriz` header icon for actions |
34
+ | Settings | Preferences | `Toggle`, `Field` and `Button` as direct children of `Screen` |
35
+ | Choice | Pick one value for a setting | `SettingsChoices` only. Save, then `back()` |
36
+ | Input | Enter or edit one value | One `TextInput` with `autoFocus`. Its keyboard action submits. Hints or errors underneath only |
37
+ | Options | Actions for one item | `Button`s only, one per action |
38
+ | Confirmation | Destructive or irreversible action | `Confirmation` as the whole page |
39
+ | Media or chat | Playback or messages | `PlayingScreen` or `ConversationScreen` |
40
+ | Custom visual | Diagrams, boards, gauges | `Canvas` primitives inside `Screen`, with Ink components for any controls |
41
+
42
+ Loading, empty and error states are not separate pages. Place `LoadingState`, `EmptyState` or `ErrorState` directly inside `Screen` in place of its content.
43
+
44
+ ## 3. Choose components
45
+
46
+ Pick the component that matches what the element means, not how you want it to look.
47
+
48
+ | To show | Use | Not |
49
+ | --- | --- | --- |
50
+ | A setting that is on or off | `Toggle label="…"` | A button whose label changes |
51
+ | A setting with a current value | `Field label="…" href="/choice"` showing the value | A button labelled with the value |
52
+ | A group of related settings | `Button href="/settings/group"` labelled with the group name | `Field` with an empty value |
53
+ | An action | `Button` labelled with a verb | `Text` with `onPress` |
54
+ | An item that opens | `Row` with `title`, and `subtitle` or `image` when useful | A `Stack` of `Text` with `onPress` |
55
+ | A scrolling collection | `List` | `.map()` over many items inside `Stack` |
56
+ | A user-ordered collection | `ReorderList` on its own page, reached from a `swapVert` header icon | Up and down buttons |
57
+ | A one-of-many choice | `SettingsChoices` on a choice page | Toggles that turn each other off, or `+` and `−` buttons |
58
+ | Content that stays above scrolling | `Screen header` | A custom pinned view |
59
+ | Up to three persistent bottom actions | `Screen actionBar` | Buttons placed against the screen edge |
60
+ | A secondary navigation action | `leftAction` or `rightAction` with an icon from `ink/icons` | A button in the content |
61
+ | Supporting text | `Text size={18}` below what it explains | A paragraph above the controls |
62
+ | A large primary reading | One `Text` with a large `size` and `tabularNumbers` | Several large sizes on one page |
63
+
64
+ Common header icons: `settings` opens Settings, `add` creates an item, `moreHoriz` opens options, `swapVert` reorders, `sort` changes order, `check` saves an edit, and `graphicEq` opens the playing screen.
65
+
66
+ ## 4. Lay out the page
67
+
68
+ - Put components directly inside `Screen`. `Screen` already spaces its children 47 pixels apart and supplies side padding and scrolling. Do not wrap the whole page in a `Stack`.
69
+ - Use `Stack` only for elements that belong together and need tighter spacing than the page, such as a reading above its unit, or a horizontal row of labels. Use a gap of 16 or less inside a group.
70
+ - Use `Stack justify="space-between"` as the only child of `Screen` to pin a footer to the bottom.
71
+ - Order a page by importance: primary content, then the controls that act on it, then supporting text.
72
+ - Order a Settings page as values first (toggles and fields), then links to groups, then destructive actions.
73
+ - Use default text size for content and labels and `size={18}` for supporting text. Add at most one larger size per page, for its primary reading.
74
+ - Centre short content with `Screen centered`. Align feeds, lists and forms to the top.
75
+
76
+ ## 5. Keep it small
77
+
78
+ Ink apps are deliberately plain. Remove anything that does not help the user complete the task.
79
+
80
+ - Do not create wrapper components that restyle or rearrange Ink components, such as cards, sections, headers, tiles or custom list items. A local component is fine when it composes Ink components for reuse across pages without changing their appearance.
81
+ - Do not add borders, backgrounds, dividers, badges, status pills or decorative icons.
82
+ - Do not set colours outside `Canvas`. Inside `Canvas`, derive colours from `useColourScheme()`.
83
+ - Do not use props that are not in Ink's type definitions. If TypeScript rejects a prop, change the design, not the types.
84
+ - Do not add explanatory paragraphs to a page. One short sentence is acceptable when the purpose or limits of a control are not obvious.
85
+ - Do not add success messages for actions whose result is visible. Navigate back, or show the changed value.
86
+ - Do not add a toggle subtitle that repeats its label. Add one only when the label alone cannot be understood.
87
+ - Do not show the same information twice, such as a status in both a label and a sentence.
88
+
89
+ ## 6. Handle state and input
90
+
91
+ - Inputs live on their own page. Use the right keyboard; its action submits or saves. Do not add a Save or Cancel button. The header Back button cancels.
92
+ - Search submits to a separate results page. Multiline inputs keep Return for new lines and use a `check` header action to save.
93
+ - Keep existing content visible during refreshes and when returning to a page. Show `LoadingState` only when there is no content yet.
94
+ - Keep a control's label and appearance while its action is pending. `useAction` already ignores repeated calls, so do not set `disabled` or change the label to “Loading…”. Reserve `disabled` for controls that are unavailable.
95
+ - Show one error at a time. Use `ErrorState` with a retry action when the page cannot show its content. Otherwise, place one `Text` beside the affected control.
96
+ - Request a permission when the user opens the feature that needs it. Do not add a separate permission button.
97
+ - Preserve scroll position, drafts and selections when navigating.
98
+ - Use `tabularNumbers` for changing numbers. Reserve space for images whose size is known.
99
+
100
+ ## Example: a setup page
101
+
102
+ This keyboard setup page mixes statuses, actions, instructions and several error messages:
103
+
104
+ ```tsx
105
+ <Screen title="Set up keyboard">
106
+ <Stack gap={24}>
107
+ <Button onPress={enable.run}>{ready?.enabled ? "Atlas is enabled" : "Enable Atlas"}</Button>
108
+ <Button onPress={choose.run}>{ready?.selected ? "Atlas is selected" : "Choose Atlas"}</Button>
109
+ <Button onPress={scroll.run}>Open Scroll assistant</Button>
110
+ <Text size={18}>Select Sliding Mode 1 for each app where you want to scroll and swipe type. Atlas reads the keyboard while a text field is active, and scrolling works everywhere else.</Text>
111
+ {(enable.status === "error" || choose.status === "error") && <Text>Could not open keyboard settings.</Text>}
112
+ {scroll.status === "error" && <Text>Scroll assistant is only available on the Titan 2.</Text>}
113
+ {/* Retry button with disabled and pending label, two more error messages */}
114
+ </Stack>
115
+ </Screen>
116
+ ```
117
+
118
+ Problems:
119
+
120
+ - Button labels change between an action and a status.
121
+ - The whole page is wrapped in a `Stack` with its own gap.
122
+ - A two-sentence paragraph sits between controls.
123
+ - Each action adds its own error line at the bottom of the page.
124
+
125
+ The same page as a Settings page type:
126
+
127
+ ```tsx
128
+ <Screen title="Set up keyboard">
129
+ {loaded.status === "error" ? <ErrorState message="Could not read keyboard settings." onRetry={reload} /> : <>
130
+ <Field label="Enabled" onPress={enable.run}>{ready.enabled ? "Yes" : "No"}</Field>
131
+ <Field label="Selected" onPress={choose.run}>{ready.selected ? "Yes" : "No"}</Field>
132
+ <Field label="Scroll assistant" onPress={scroll.run}>Sliding Mode 1</Field>
133
+ {error && <Text size={18}>{error}</Text>}
134
+ </>}
135
+ </Screen>
136
+ ```
137
+
138
+ Each row shows one status and opens the place to change it. Spacing comes from `Screen`, and there is one error line.
139
+
140
+ ## Before you finish
141
+
142
+ Check each page against this list and fix every failure before reporting the work as done:
143
+
144
+ - [ ] The page appears in the page map with one page type.
145
+ - [ ] `/` opens on the main task.
146
+ - [ ] Top-level children of `Screen` are the components listed for its page type.
147
+ - [ ] No wrapper `Stack`, custom gaps between page sections, borders, colours or decorative elements.
148
+ - [ ] No component restyles or reimplements an Ink component.
149
+ - [ ] Labels name actions or settings; values and statuses are in `Field` values or `Toggle` states.
150
+ - [ ] No paragraphs, repeated information or success messages.
151
+ - [ ] One error at a time, beside the control or as `ErrorState`.
152
+ - [ ] `ink check` passes. Its `ink-design` rules catch some of these mistakes; fix the design rather than disabling a rule.
153
+ - [ ] Each page is checked in the emulator with the keyboard open, long content, missing data, light and dark modes, and after navigating back from another page. Use the phone for hardware features.
154
+
155
+ Use plain British English and sentence case. Name buttons with direct verbs, such as “Export words”.
156
+
157
+ ## Reference apps
158
+
159
+ These apps show the intended structure. Read the matching page in one before building a new page type.
160
+
161
+ | App | Shows |
162
+ | --- | --- |
163
+ | [Tuner](https://github.com/No-Scrolling/tuner) | Main task opening on a reading, Settings with a choice page |
164
+ | [Weather](https://github.com/No-Scrolling/weather) | Tabs, grouped Settings, unit choice pages, search input and results |
165
+ | [Podcasts](https://github.com/No-Scrolling/podcasts) | Collection, detail, options page, Player settings, `PlayingScreen` |
166
+ | [Passes](https://github.com/No-Scrolling/passes) | Collection with reorder, options page, rename input, delete confirmation |
167
+
168
+ See the [Ink documentation](https://ink.noscroll.ing/screens) for each component's props and examples. Ink's type definitions in `node_modules/ink/src` list every supported prop.
package/package.json CHANGED
@@ -1,23 +1,27 @@
1
1
  {
2
2
  "name": "ink-framework",
3
- "version": "0.1.0-alpha.10",
3
+ "version": "0.1.0-alpha.13",
4
4
  "types": "./src/index.ts",
5
5
  "exports": {
6
6
  ".": "./src/index.ts",
7
7
  "./renderer": "./src/renderer.ts",
8
8
  "./internal/list": "./src/native-list.ts",
9
+ "./internal/data": "./src/data-views.ts",
9
10
  "./internal/playback": "./src/playback-binding.ts",
11
+ "./internal/controller": "./src/controller-react.ts",
10
12
  "./native": "./src/native.ts",
11
13
  "./native/controller": "./src/controller.ts",
12
14
  "./assets": "./src/assets.ts",
13
15
  "./icons": "./src/icons.ts",
14
16
  "./file-router": "./src/file-router.tsx",
15
17
  "./runtime-types": "./runtime.d.ts",
16
- "./tsconfig": "./tsconfig.app.json"
18
+ "./tsconfig": "./tsconfig.app.json",
19
+ "./internal/react": "./src/react.ts",
20
+ "./resource": "./src/resource.ts"
17
21
  },
18
22
  "dependencies": {
19
23
  "linkify-it": "^6.1.0",
20
- "react-reconciler": "0.33.0"
24
+ "preact": "10.29.3"
21
25
  },
22
26
  "peerDependencies": {
23
27
  "react": "19.2.8"
@@ -34,6 +38,7 @@
34
38
  "files": [
35
39
  "src",
36
40
  "*.json",
37
- "*.ts"
41
+ "*.ts",
42
+ "*.md"
38
43
  ]
39
44
  }
package/src/canvas.ts CHANGED
@@ -15,10 +15,69 @@ export function Rectangle(props: Bounds & {
15
15
  onPress?: () => void;
16
16
  onLongPress?: () => void;
17
17
  onDragEnter?: () => void;
18
+ onDragStart?: () => void;
19
+ onDragEnd?: () => void;
20
+ haptic?: boolean;
18
21
  }) {
19
22
  return createElement("CanvasRectangle", props);
20
23
  }
21
24
 
25
+ /** Native-owned horizontal selection. Remount to reset its initial position. */
26
+ export function CanvasRange({
27
+ total, span, initialStart, step = 1, snapPoints = [], snapDistance = 6, tapSnapDistance = 10, hitSlop = 10,
28
+ minDragWidth = 0, inset = 4, innerFill = "#666666", dragOnly = false, snapOnTap = false, progress = 0,
29
+ progressDurationMs = 0, progressPlaying = false, progressLoop = false, progressFill = "#FFFFFF", ...props
30
+ }: Bounds & {
31
+ total: number;
32
+ span: number;
33
+ initialStart: number;
34
+ step?: number;
35
+ snapPoints?: readonly number[];
36
+ /** Distances and inset use Canvas logical units. Drag release distance is 1.5× entry. */
37
+ snapDistance?: number;
38
+ tapSnapDistance?: number;
39
+ hitSlop?: number;
40
+ /** Minimum selection drag target width; does not change drawing or tap-to-seek. */
41
+ minDragWidth?: number;
42
+ inset?: number;
43
+ fill?: string;
44
+ innerFill?: string;
45
+ /** Move only by dragging the selection; taps inside emit a relative seek. */
46
+ dragOnly?: boolean;
47
+ /** In drag-only mode, marker taps move the selection before considering a seek. */
48
+ snapOnTap?: boolean;
49
+ /** Playback fraction inside the selection, from zero to one. */
50
+ progress?: number;
51
+ /** Enables native playback interpolation; zero keeps progress static. */
52
+ progressDurationMs?: number;
53
+ /** Actual playback advancement, false while paused or buffering. */
54
+ progressPlaying?: boolean;
55
+ progressLoop?: boolean;
56
+ progressFill?: string;
57
+ onRangeSeek?: (position: number) => void;
58
+ onRangeStart?: () => void;
59
+ onRangeCommit?: (start: number) => void;
60
+ /** FIFO snapshot before another press or Back; never emitted for movement. */
61
+ onRangeSnapshot?: (start: number) => void;
62
+ }) {
63
+ return createElement("CanvasRectangle", {
64
+ ...props,
65
+ range: {
66
+ total, span, initialStart, step, snapPoints, snapDistance, tapSnapDistance, hitSlop, minDragWidth, inset,
67
+ innerFill, dragOnly, snapOnTap, progress, progressDurationMs, progressPlaying, progressLoop, progressFill,
68
+ },
69
+ });
70
+ }
71
+
72
+ /** A line through points in canvas units, with round ends and joins. Paths draw above the canvas's rectangles and text. */
73
+ export function CanvasPath({ points, ...props }: {
74
+ points: readonly (readonly [x: number, y: number])[];
75
+ stroke?: string;
76
+ strokeWidth?: number;
77
+ }) {
78
+ return createElement("CanvasPath", { ...props, points: points.flat() });
79
+ }
80
+
22
81
  export function CanvasText({ children, ...props }: Bounds & {
23
82
  children: string | number;
24
83
  size?: number;
@@ -0,0 +1,6 @@
1
+ import { useMemo } from "preact/hooks";
2
+
3
+ /** React Compiler's per-component memo slots, retained across Preact renders. */
4
+ export function c(size: number): unknown[] {
5
+ return useMemo(() => Array(size).fill(Symbol.for("react.memo_cache_sentinel")), []);
6
+ }
@@ -0,0 +1,33 @@
1
+ import { useMemo } from "./react";
2
+ import { attachNativeController } from "./controller";
3
+
4
+ type Attachment = ReturnType<typeof attachNativeController>;
5
+ type ControllerAttachment = {
6
+ current: Attachment | null;
7
+ attach(recipe: unknown, observe: (value: unknown) => void, fail: (error: Error) => void,
8
+ ready?: (id: number) => void): () => void;
9
+ };
10
+
11
+ /** @internal Attach from an effect and return its cleanup, including for hidden Activities. */
12
+ export function useNativeController(module: string, description = module): ControllerAttachment {
13
+ return useMemo(() => {
14
+ const controller: ControllerAttachment = {
15
+ current: null,
16
+ attach(recipe, observe, fail, ready) {
17
+ const attachment = attachNativeController(module, recipe, observe);
18
+ controller.current = attachment;
19
+ // Disposal removes event observers; promise continuations need their own lifetime check.
20
+ void attachment.ready.then(() => {
21
+ if (controller.current === attachment) ready?.(attachment.id);
22
+ }, error => {
23
+ if (controller.current === attachment) fail(error);
24
+ });
25
+ return () => {
26
+ controller.current = null;
27
+ void attachment.dispose().catch(error => console.error(`Could not release ${description}`, error));
28
+ };
29
+ },
30
+ };
31
+ return controller;
32
+ }, [module, description]);
33
+ }
@@ -0,0 +1,249 @@
1
+ type IntrinsicCalls = {
2
+ filter<T>(items: readonly T[], predicate: (item: T, index: number) => boolean): T[];
3
+ sort<T>(items: T[], compare: (a: T, b: T) => number): T[];
4
+ map<T, U>(items: readonly T[], transform: (item: T, index: number) => U): U[];
5
+ lower(text: string): string;
6
+ includes(text: string, query: string): boolean;
7
+ localeCompare(a: string, b: string): number;
8
+ dateParse(value: unknown): number;
9
+ jsonParse(source: string, reviver: (this: object, key: string, value: unknown) => unknown): unknown;
10
+ weakMapGet<T>(map: WeakMap<object, T>, key: object): T | undefined;
11
+ weakMapSet<T>(map: WeakMap<object, T>, key: object, value: T): unknown;
12
+ mapGet<K, V>(map: Map<K, V>, key: K): V | undefined;
13
+ mapSet<K, V>(map: Map<K, V>, key: K, value: V): unknown;
14
+ };
15
+
16
+ type Intrinsics = {
17
+ WeakMap: WeakMapConstructor;
18
+ Map: MapConstructor;
19
+ calls?: IntrinsicCalls;
20
+ setTimeout?: (callback: () => void, delay: number) => number;
21
+ clearTimeout?: (timer: number) => void;
22
+ };
23
+
24
+ declare const __inkDataIntrinsics: Intrinsics | undefined;
25
+ declare const __inkDataFields: ((items: unknown, fields: readonly string[], previous: readonly unknown[] | undefined,
26
+ mode: "search" | "sort") => readonly unknown[] | undefined) | undefined;
27
+ declare const __inkDataSearch: ((items: unknown, fields: readonly string[], previousSnapshot: readonly unknown[] | undefined,
28
+ previousText: readonly string[] | undefined, separator: string | null, query?: () => unknown,
29
+ queryLower?: boolean) => (SearchCache & { items?: unknown[]; needle?: string }) | undefined) | undefined;
30
+ declare const __inkPrepareDataSearch: ((items: unknown, fields: readonly string[], separator: string | null,
31
+ cursor: SearchCursor | undefined, limit: number) => SearchCursor | null | undefined) | undefined;
32
+
33
+ const intrinsics = typeof __inkDataIntrinsics === "undefined" ? undefined : __inkDataIntrinsics;
34
+ const calls = intrinsics?.calls;
35
+ const snapshotFields = typeof __inkDataFields === "function" ? __inkDataFields : undefined;
36
+ const prepareSearch = typeof __inkDataSearch === "function" ? __inkDataSearch : undefined;
37
+ const warmSearch = typeof __inkPrepareDataSearch === "function" ? __inkPrepareDataSearch : undefined;
38
+ const schedule = intrinsics?.setTimeout;
39
+ const unschedule = intrinsics?.clearTimeout;
40
+
41
+ type RawPlan = { fields: unknown; separator: unknown; queryLower: unknown; date: unknown; direction: unknown; tie: unknown };
42
+ type SearchCache = { snapshot: readonly unknown[]; text: readonly string[] };
43
+ interface CachedSearch extends SearchCache { changes: number }
44
+ type SearchCursor = SearchCache & { offset: number; complete: boolean };
45
+ type SearchJob = { items: object | undefined; cursor: SearchCursor | undefined; timer: number | undefined };
46
+ type SearchPlan = {
47
+ fields: readonly string[];
48
+ separator: string | null;
49
+ queryLower: boolean;
50
+ cache: WeakMap<object, CachedSearch | undefined>;
51
+ seen: WeakMap<object, boolean>;
52
+ job: SearchJob | undefined;
53
+ retained: boolean;
54
+ next: SearchPlan | undefined;
55
+ };
56
+ type SortPlan = { fields: readonly string[]; direction: 1 | -1; tie: boolean };
57
+ type SortEntry<T> = { item: T; date: number; tie: string };
58
+
59
+ // Plans are compiler literals. Bound retained plans as well as keeping source arrays weak.
60
+ const maximumPlans = 64;
61
+ let searches: Map<string, SearchPlan> | undefined;
62
+ let searchCount = 0;
63
+ let searchHead: SearchPlan | undefined;
64
+ let sorts: Map<string, SortPlan> | undefined;
65
+ let sortCount = 0;
66
+
67
+ function parsePlan(source: string): RawPlan | undefined {
68
+ const i = intrinsics!;
69
+ const records = new i.WeakMap<object, RawPlan>();
70
+ try {
71
+ // Collect only JSON's own members, including when Object.prototype has acquired getters.
72
+ const parsed = calls!.jsonParse(source, function(this: object, key: string, value: unknown) {
73
+ if (key === "fields" || key === "separator" || key === "queryLower" || key === "date" || key === "direction" || key === "tie") {
74
+ let record = calls!.weakMapGet(records, this);
75
+ if (!record) {
76
+ record = { fields: undefined, separator: undefined, queryLower: undefined, date: undefined, direction: undefined, tie: undefined };
77
+ calls!.weakMapSet(records, this, record);
78
+ }
79
+ record[key] = value;
80
+ }
81
+ return value;
82
+ });
83
+ return parsed !== null && typeof parsed === "object"
84
+ ? calls!.weakMapGet(records, parsed) : undefined;
85
+ } catch {
86
+ return undefined;
87
+ }
88
+ }
89
+
90
+ function searchPlan(source: string): SearchPlan | undefined {
91
+ const i = intrinsics!;
92
+ const known = searches && calls!.mapGet(searches, source);
93
+ if (known) return known;
94
+ const raw = parsePlan(source);
95
+ if (!raw || raw.separator !== null && typeof raw.separator !== "string"
96
+ || raw.queryLower !== undefined && typeof raw.queryLower !== "boolean") return undefined;
97
+ // The native guard validates the fields array without invoking getters or Proxy traps.
98
+ const plan: SearchPlan = { fields: raw.fields as readonly string[], separator: raw.separator,
99
+ queryLower: raw.queryLower === true, cache: new i.WeakMap<object, CachedSearch | undefined>(),
100
+ seen: new i.WeakMap<object, boolean>(), job: undefined, retained: true, next: undefined };
101
+ if (!searches || searchCount === maximumPlans) {
102
+ while (searchHead) {
103
+ const evicted = searchHead;
104
+ searchHead = evicted.next;
105
+ evicted.next = undefined;
106
+ evicted.retained = false;
107
+ cancelSearch(evicted);
108
+ }
109
+ searches = new i.Map<string, SearchPlan>();
110
+ searchCount = 0;
111
+ }
112
+ calls!.mapSet(searches, source, plan);
113
+ plan.next = searchHead;
114
+ searchHead = plan;
115
+ searchCount++;
116
+ return plan;
117
+ }
118
+
119
+ function cancelSearch(plan: SearchPlan): void {
120
+ const job = plan.job;
121
+ if (!job) return;
122
+ plan.job = undefined;
123
+ const timer = job.timer;
124
+ job.items = undefined;
125
+ job.cursor = undefined;
126
+ job.timer = undefined;
127
+ if (timer !== undefined) {
128
+ try { unschedule!(timer); } catch { /* Cancellation has already invalidated the job. */ }
129
+ }
130
+ }
131
+
132
+ function disableSearch(plan: SearchPlan, items: object): void {
133
+ calls!.weakMapSet(plan.cache, items, undefined);
134
+ calls!.weakMapSet(plan.seen, items, false);
135
+ }
136
+
137
+ function warmLater(plan: SearchPlan, items: object): void {
138
+ if (!plan.retained || plan.job?.items === items) return;
139
+ cancelSearch(plan);
140
+ const job: SearchJob = { items, cursor: undefined, timer: undefined };
141
+ plan.job = job;
142
+ const step = () => {
143
+ if (plan.job !== job) return;
144
+ job.timer = undefined;
145
+ try {
146
+ const source = job.items!;
147
+ const cursor = warmSearch!(source, plan.fields, plan.separator, job.cursor, 128);
148
+ if (plan.job !== job) return;
149
+ if (!cursor) {
150
+ // Unsupported shapes stay on the original path. A shrinking source (null)
151
+ // and thrown background errors remain eligible for a later attempt.
152
+ if (cursor === undefined) calls!.weakMapSet(plan.seen, source, false);
153
+ cancelSearch(plan);
154
+ } else if (cursor.complete) {
155
+ calls!.weakMapSet(plan.cache, source, { snapshot: cursor.snapshot, text: cursor.text, changes: 0 });
156
+ cancelSearch(plan);
157
+ } else {
158
+ job.cursor = cursor;
159
+ job.timer = schedule!(step, 0);
160
+ }
161
+ } catch {
162
+ // Background work must never turn a successful original search into an error.
163
+ if (plan.job === job) cancelSearch(plan);
164
+ }
165
+ };
166
+ try { job.timer = schedule!(step, 0); } catch { cancelSearch(plan); }
167
+ }
168
+
169
+ function sortPlan(source: string): SortPlan | undefined {
170
+ const i = intrinsics!;
171
+ const known = sorts && calls!.mapGet(sorts, source);
172
+ if (known) return known;
173
+ const raw = parsePlan(source);
174
+ if (!raw || typeof raw.date !== "string" || raw.direction !== 1 && raw.direction !== -1
175
+ || raw.tie !== undefined && typeof raw.tie !== "string") return undefined;
176
+ const plan: SortPlan = { fields: typeof raw.tie === "string" ? [raw.date, raw.tie] : [raw.date],
177
+ direction: raw.direction, tie: typeof raw.tie === "string" };
178
+ if (!sorts || sortCount === maximumPlans) {
179
+ sorts = new i.Map<string, SortPlan>();
180
+ sortCount = 0;
181
+ }
182
+ calls!.mapSet(sorts, source, plan);
183
+ sortCount++;
184
+ return plan;
185
+ }
186
+
187
+ /** @internal The compiler supplies the original filter expression as the fallback. */
188
+ export function compiledSearch<T>(items: readonly T[], source: string, query: () => unknown, fallback: (items: readonly T[]) => T[]): T[] {
189
+ if (!intrinsics || !calls || !prepareSearch || !warmSearch || !schedule || !unschedule) return fallback(items);
190
+ let plan: SearchPlan | undefined;
191
+ try { plan = searchPlan(source); } catch { return fallback(items); }
192
+ if (!plan) return fallback(items);
193
+ if (plan.job && plan.job.items !== items) cancelSearch(plan);
194
+ const before = calls.weakMapGet(plan.cache, items);
195
+ if (!before) {
196
+ const result = fallback(items);
197
+ if (items !== null && (typeof items === "object" || typeof items === "function")) {
198
+ try {
199
+ const seen = calls.weakMapGet(plan.seen, items);
200
+ if (seen === true) warmLater(plan, items);
201
+ else if (seen === undefined) calls.weakMapSet(plan.seen, items, true);
202
+ } catch { /* Retaining a warming candidate is optional. */ }
203
+ }
204
+ return result;
205
+ }
206
+ const prepared = prepareSearch(items, plan.fields, before.snapshot, before.text, plan.separator, query, plan.queryLower);
207
+ if (!prepared) {
208
+ disableSearch(plan, items);
209
+ return fallback(items);
210
+ }
211
+ // The native guard leaves queries unevaluated for rejected or empty sources.
212
+ let result: T[];
213
+ // The guard returns current own row values without converting them.
214
+ if (prepared.items) result = prepared.items as T[];
215
+ else if (typeof prepared.needle === "string") {
216
+ // A query can change descriptors or species after preparation. Use the
217
+ // original filter operation with the already evaluated query in that case.
218
+ const needle = prepared.needle;
219
+ result = calls.filter(items, (_item: T, index: number) => calls.includes(prepared.text[index], needle));
220
+ } else return fallback(items);
221
+ const changes = prepared.snapshot === before.snapshot ? 0 : before.changes + 1;
222
+ if (changes >= 2) disableSearch(plan, items);
223
+ else calls.weakMapSet(plan.cache, items, { snapshot: prepared.snapshot, text: prepared.text, changes });
224
+ return result;
225
+ }
226
+
227
+ /** @internal Sort date keys once per invocation while retaining Array.sort's source identity. */
228
+ export function compiledSort<T>(items: T[], source: string, fallback: (items: T[]) => T[]): T[] {
229
+ if (!intrinsics || !calls || !snapshotFields) return fallback(items);
230
+ const plan = sortPlan(source);
231
+ if (!plan) return fallback(items);
232
+ const snapshot = snapshotFields(items, plan.fields, undefined, "sort");
233
+ if (!snapshot || items.length < 2) return fallback(items);
234
+ const width = plan.fields.length;
235
+ for (let offset = 0; offset < snapshot.length; offset += width) {
236
+ const date = snapshot[offset];
237
+ if (date !== null && typeof date === "object" || typeof date === "function" || typeof date === "symbol"
238
+ || plan.tie && typeof snapshot[offset + 1] !== "string") return fallback(items);
239
+ }
240
+ const entries = calls.map(items, (item: T, index: number): SortEntry<T> => ({ item,
241
+ date: calls.dateParse(snapshot[index * width] ?? "") || 0,
242
+ tie: plan.tie ? snapshot[index * width + 1] as string : "" }));
243
+ calls.sort(entries, (a: SortEntry<T>, b: SortEntry<T>) => {
244
+ const order = plan.direction * (a.date - b.date);
245
+ return order || (plan.tie ? calls.localeCompare(a.tie, b.tie) : 0);
246
+ });
247
+ for (let index = 0; index < entries.length; index++) items[index] = entries[index].item;
248
+ return items;
249
+ }
@@ -1,4 +1,4 @@
1
- import { Activity, createElement, useMemo, type ComponentType, type ReactNode } from "react";
1
+ import { Activity, createElement, useMemo, type ComponentType, type ReactNode } from "./react";
2
2
  import { back, LayoutContext, NavigationStack, RouteContext, type Destination, type Entry } from "./navigation";
3
3
 
4
4
  export type FileRoute = { name: string; path: string; component: ComponentType };
@@ -0,0 +1,65 @@
1
+ import { createElement, type ComponentProps, type ReactElement } from "./react";
2
+ import { Button, type StackProps, type TextProps } from "./index";
3
+ import type { IconAsset } from "./assets";
4
+ import { listText, nativeListRow, nativeListTemplate } from "./native-list";
5
+
6
+ type SharedProps = { key?: string | number; hidden?: boolean };
7
+ type RowProps = {
8
+ Stack: SharedProps & Omit<StackProps, "children">;
9
+ Text: SharedProps & Omit<TextProps, "children" | "href" | "url"> & { text: string };
10
+ Button: SharedProps & Pick<ComponentProps<typeof Button>, "icon" | "disabled" | "selected" | "onPress" | "onLongPress">;
11
+ MediaGridRow: SharedProps;
12
+ MediaCell: SharedProps & {
13
+ src: string; selected?: boolean; dimmed?: boolean; video?: boolean;
14
+ checkIcon?: IconAsset; videoIcon?: IconAsset;
15
+ onPress?: () => void; onLongPress?: () => void;
16
+ };
17
+ };
18
+ type RowValue = string | number | boolean | undefined | (() => void);
19
+ type RowField<Fields, Value extends RowValue> = { readonly read: (fields: Fields) => Value };
20
+ type BoundProps<Fields, Props> = { [Name in keyof Props]: (Props[Name] & RowValue) | RowField<Fields, Props[Name] & RowValue> };
21
+ type RowNode<Fields> = {
22
+ type: keyof RowProps;
23
+ props: Record<string, RowValue | RowField<Fields, RowValue>>;
24
+ children: RowNode<Fields>[];
25
+ };
26
+
27
+ /** @internal A fixed built-in presentation with React and native list adapters. */
28
+ export function fixedListRow<Fields>(build: (
29
+ node: <Kind extends keyof RowProps>(type: Kind, props: BoundProps<Fields, RowProps[NoInfer<Kind>]>, children?: RowNode<Fields>[]) => RowNode<Fields>,
30
+ field: <Value extends RowValue>(read: (fields: Fields) => Value) => RowField<Fields, Value>,
31
+ ) => RowNode<Fields>) {
32
+ class Field<Value extends RowValue> {
33
+ constructor(readonly read: (fields: Fields) => Value) {}
34
+ }
35
+ const root = build((type, props, children = []) => ({ type, props, children }), read => new Field(read));
36
+ const readers: ((fields: Fields) => RowValue)[] = [];
37
+ const template = nativeListTemplate((node, field) => {
38
+ const visit = (row: RowNode<Fields>): ReturnType<typeof node> => node(row.type,
39
+ Object.fromEntries(Object.entries(row.props).flatMap(([name, value]) => {
40
+ if (name === "key") return [];
41
+ if (value instanceof Field || name.startsWith("on")) {
42
+ const read = value instanceof Field ? value.read : () => value;
43
+ return [[name, field(readers.push(read) - 1)]];
44
+ }
45
+ return [[name, value]];
46
+ })), row.children.map(visit));
47
+ return visit(root);
48
+ });
49
+ function render(row: RowNode<Fields>, fields: Fields): ReactElement | null {
50
+ const props = Object.fromEntries(Object.entries(row.props).map(([name, value]) =>
51
+ [name, value instanceof Field ? value.read(fields) : value]));
52
+ if (props.hidden === true) return null;
53
+ if (row.type === "Text") {
54
+ const { text, ...rest } = props;
55
+ return createElement("Text", rest, listText([text]));
56
+ }
57
+ return createElement(row.type === "Button" ? Button : row.type, props, ...row.children.map(child => render(child, fields)));
58
+ }
59
+ return function<T>(project: (item: T, index: number) => Fields) {
60
+ return nativeListRow((item: T, index: number) => render(root, project(item, index)), (item, index) => {
61
+ const fields = project(item, index);
62
+ return [readers.map(read => read(fields))];
63
+ }, template);
64
+ };
65
+ }