ink-framework 0.1.0-alpha.12 → 0.1.0-alpha.14

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/src/list.ts CHANGED
@@ -5,9 +5,7 @@ import { Button, Stack, Text } from "./index";
5
5
  import { CompiledList, nativeListPlan } from "./native-list";
6
6
  import { ScrollToEndContext } from "./scroll";
7
7
 
8
- const OLDER_PAGE_AHEAD_ITEMS = 8;
9
-
10
- export function ListLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
8
+ function ListLoadError({ message, onRetry }: { message: string; onRetry: () => void }) {
11
9
  return createElement(Stack, { gap: 24 },
12
10
  createElement(Text, { size: 18 }, message),
13
11
  createElement(Button, { onPress: onRetry }, "Try again"),
@@ -28,7 +26,65 @@ export type ListProps<T> = {
28
26
  initialPosition?: "start" | "end";
29
27
  };
30
28
 
29
+ type PaginationProps = Pick<ListProps<unknown>, "onLoadMore" | "hasMore" | "onLoadOlder" | "hasOlder">;
30
+ export type ListPagination = ReturnType<typeof useListPagination>;
31
+
32
+ function useListPage(load: (() => Promise<void>) | undefined, available: boolean) {
33
+ const accepted = useRef<string | undefined>(undefined);
34
+ const action = useAction((edge?: string) => {
35
+ // useAction may decline a pending request; record acceptance only when it starts.
36
+ if (edge !== undefined) accepted.current = edge;
37
+ return load?.();
38
+ });
39
+ return {
40
+ available: available && action.status !== "error",
41
+ enabled: !!load,
42
+ pending: action.status === "pending",
43
+ error: action.status === "error" ? action.error : undefined,
44
+ retry: () => action.run(),
45
+ request(edge: string) {
46
+ if (available && load && action.status !== "pending" && action.status !== "error" && accepted.current !== edge) action.run(edge);
47
+ },
48
+ };
49
+ }
50
+
51
+ function useListPagination({ onLoadMore, hasMore = false, onLoadOlder, hasOlder = false }: PaginationProps) {
52
+ const more = useListPage(onLoadMore, hasMore);
53
+ const older = useListPage(onLoadOlder, hasOlder);
54
+ return {
55
+ more,
56
+ older,
57
+ wrap(list: ReactNode) {
58
+ return createElement(Fragment, null,
59
+ older.error && createElement(ListLoadError, { message: older.error.message, onRetry: older.retry }),
60
+ list,
61
+ more.error && createElement(ListLoadError, { message: more.error.message, onRetry: more.retry }),
62
+ );
63
+ },
64
+ };
65
+ }
66
+
67
+ export function useListPaginationEvents(pagination: ListPagination, paused: boolean, count: number, first?: string, last?: string) {
68
+ const [startRequest, setStartRequest] = useState<string>();
69
+ const [endRequest, setEndRequest] = useState<string>();
70
+ const start = JSON.stringify([count, first ?? null]);
71
+ const end = JSON.stringify([count, last ?? null]);
72
+ useEffect(() => {
73
+ if (!paused && startRequest === start) pagination.older.request(startRequest);
74
+ if (!paused && endRequest === end) pagination.more.request(endRequest);
75
+ }, [pagination, paused, startRequest, endRequest, start, end]);
76
+ return {
77
+ hasMore: !paused && pagination.more.available && !pagination.more.pending,
78
+ hasOlder: !paused && pagination.older.available && !pagination.older.pending,
79
+ moreAvailable: pagination.more.available && pagination.more.enabled,
80
+ // Retain notifications even when the current commit cannot start loading yet.
81
+ onEndReached: setEndRequest,
82
+ onStartReached: setStartRequest,
83
+ };
84
+ }
85
+
31
86
  export function List<T>(props: ListProps<T>) {
87
+ const pagination = useListPagination(props);
32
88
  const requestEnd = useContext(ScrollToEndContext);
33
89
  const initialised = useRef(false);
34
90
  useLayoutEffect(() => {
@@ -37,10 +93,12 @@ export function List<T>(props: ListProps<T>) {
37
93
  if (props.initialPosition === "end") requestEnd?.();
38
94
  }, [props.initialPosition, props.items.length, requestEnd]);
39
95
  const plan = nativeListPlan(props.renderItem);
40
- return plan ? createElement(CompiledList<T>, { ...props, plan, key: plan.template }) : createElement(ReactList<T>, props);
96
+ return pagination.wrap(plan
97
+ ? createElement(CompiledList<T>, { ...props, pagination, plan, key: plan.template })
98
+ : createElement(ReactList<T>, { ...props, pagination }));
41
99
  }
42
100
 
43
- export function ReactList<T>({ items, keyExtractor, renderItem, gap = 47, followEnd = false, measurementKey, onLoadMore, hasMore = false, onLoadOlder, hasOlder = false, initialPosition = "start" }: ListProps<T>) {
101
+ export function ReactList<T>({ items, keyExtractor, renderItem, gap = 47, followEnd = false, measurementKey, initialPosition = "start", pagination }: ListProps<T> & { pagination: ListPagination }) {
44
102
  if (!Number.isFinite(gap) || gap < 0) {
45
103
  throw new Error("List gap must be finite and non-negative");
46
104
  }
@@ -51,11 +109,7 @@ export function ReactList<T>({ items, keyExtractor, renderItem, gap = 47, follow
51
109
  items: readonly T[]; keys: string[]; contentVersions: number[]; revision: number;
52
110
  records: Map<string, { item: T; version: number }>;
53
111
  } | null>(null);
54
- const [window, setWindow] = useState({ start: initialPosition === "end" ? Math.max(0, items.length - 32) : 0, end: initialPosition === "end" ? items.length : 32, revision: 0, nearEnd: false });
55
- const requested = useRef<string | null>(null);
56
- const load = useAction(() => onLoadMore?.());
57
- const olderRequested = useRef<string | null>(null);
58
- const older = useAction(() => onLoadOlder?.());
112
+ const [window, setWindow] = useState({ start: initialPosition === "end" ? Math.max(0, items.length - 32) : 0, end: initialPosition === "end" ? items.length : 32, revision: 0 });
59
113
  let start = Math.min(window.start, items.length);
60
114
  let end = Math.min(window.end, items.length);
61
115
  if (initialWindow.current && items.length) {
@@ -80,12 +134,12 @@ export function ReactList<T>({ items, keyExtractor, renderItem, gap = 47, follow
80
134
  throw new Error("List metadata exceeds 128 KiB; use shorter keys or a bounded data window");
81
135
  }
82
136
  if (initialWindow.current && items.length) {
83
- if (start !== window.start || end !== window.end) setWindow({ start, end, revision, nearEnd: false });
137
+ if (start !== window.start || end !== window.end) setWindow({ start, end, revision });
84
138
  } else if (old && window.revision !== revision && old.keys[window.start] !== undefined) {
85
139
  const mapped = keys.indexOf(old.keys[window.start]);
86
140
  start = mapped < 0 ? Math.min(window.start, Math.max(0, items.length - 1)) : mapped;
87
141
  end = Math.min(start + window.end - window.start, items.length);
88
- if (start !== window.start || end !== window.end) setWindow({ start, end, revision, nearEnd: false });
142
+ if (start !== window.start || end !== window.end) setWindow({ start, end, revision });
89
143
  }
90
144
  data = { items, keys, keyExtractor, contentVersions, revision, records, measurementKey };
91
145
  }
@@ -95,40 +149,23 @@ export function ReactList<T>({ items, keyExtractor, renderItem, gap = 47, follow
95
149
  if (items.length) initialWindow.current = false;
96
150
  });
97
151
  const { revision, contentVersions } = data;
98
- const boundary = JSON.stringify([keys.length, keys.at(-1)]);
99
- useEffect(() => {
100
- if (!onLoadMore || !hasMore || load.status === "pending" || load.status === "error"
101
- || window.revision !== revision || !window.nearEnd || requested.current === boundary) return;
102
- requested.current = boundary;
103
- load.run();
104
- }, [onLoadMore, hasMore, load.status, load.run, window, revision, boundary]);
105
- const firstBoundary = JSON.stringify([keys.length, keys[0]]);
106
- useEffect(() => {
107
- if (!onLoadOlder || !hasOlder || older.status === "pending" || older.status === "error"
108
- || window.revision !== revision || window.start > OLDER_PAGE_AHEAD_ITEMS || olderRequested.current === firstBoundary) return;
109
- olderRequested.current = firstBoundary;
110
- older.run();
111
- }, [onLoadOlder, hasOlder, older.status, older.run, window, revision, firstBoundary]);
152
+ const paginationProps = useListPaginationEvents(pagination, window.revision !== revision, keys.length, keys[0], keys.at(-1));
112
153
  const children: ReactNode[] = [];
113
154
  for (let index = start; index < end; index++) {
114
155
  children.push(createElement("Stack", { key: keys[index], axis: "vertical", align: "stretch" }, renderItem(items[index], index)));
115
156
  }
116
157
  const props = {
158
+ ...paginationProps,
117
159
  keys: data.keys,
118
160
  contentVersions,
119
161
  revision,
120
162
  gap,
121
163
  followEnd,
122
164
  start,
123
- onWindow(start: number, end: number, eventRevision: number, nearEnd: boolean) {
165
+ onWindow(start: number, end: number, eventRevision: number) {
124
166
  if (eventRevision !== revision) return;
125
- setWindow(previous => previous.start === start && previous.end === end && previous.revision === eventRevision && previous.nearEnd === nearEnd ? previous : { start, end, revision: eventRevision, nearEnd });
167
+ setWindow(previous => previous.start === start && previous.end === end && previous.revision === eventRevision ? previous : { start, end, revision: eventRevision });
126
168
  },
127
169
  };
128
- const list = createElement("List", props, children);
129
- return createElement(Fragment, null,
130
- older.status === "error" && createElement(ListLoadError, { message: older.error.message, onRetry: older.run }),
131
- list,
132
- load.status === "error" && createElement(ListLoadError, { message: load.error.message, onRetry: load.run }),
133
- );
170
+ return createElement("List", props, children);
134
171
  }
@@ -1,10 +1,11 @@
1
- import { createElement, Fragment, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from "./react";
2
- import { ListLoadError, ReactList, type ListProps } from "./list";
3
- import { useAction } from "./action";
1
+ import { createElement, useLayoutEffect, useMemo, useRef, useState, type ComponentProps, type ReactNode } from "./react";
2
+ import { ReactList, useListPaginationEvents, type ListPagination, type ListProps } from "./list";
4
3
  import type { Row } from "./image";
5
4
  import { pressHandler } from "./press";
6
5
  import { rememberListPatch, type NativeListItem } from "./list-patch";
7
6
 
7
+ export { fixedListRow } from "./fixed-list-row";
8
+
8
9
  type Captured = { value: unknown; prototype?: object | null; fields?: [PropertyKey, boolean, Captured][] };
9
10
  type Scope = { identity: string; values: readonly Captured[] };
10
11
  type Capture = { identity: string; values: () => readonly unknown[]; usesIndex?: boolean };
@@ -73,7 +74,7 @@ export function listText(parts: unknown[]): string {
73
74
  export function listRowFields(props: ComponentProps<typeof Row>) {
74
75
  return { image: props.image, title: props.title, titleMaxLines: props.titleMaxLines,
75
76
  titleIcon: props.titleIcon, subtitle: props.subtitle, subtitleIcon: props.subtitleIcon,
76
- onLongPress: props.onLongPress,
77
+ selected: props.selected, onLongPress: props.onLongPress,
77
78
  onPress: pressHandler(props) ?? (props.onLongPress ? () => {} : undefined) };
78
79
  }
79
80
 
@@ -286,11 +287,9 @@ export function nativeListPlan<T>(render: ListProps<T>["renderItem"]): Plan<T> |
286
287
  return plans.get(render) as Plan<T> | undefined;
287
288
  }
288
289
 
289
- export function CompiledList<T>({ plan, ...props }: ListProps<T> & { plan: Plan<T> }) {
290
- const { items, keyExtractor, gap = 47, followEnd, initialPosition, measurementKey, onLoadMore, onLoadOlder, hasMore = false, hasOlder = false } = props;
290
+ export function CompiledList<T>({ plan, pagination, ...props }: ListProps<T> & { plan: Plan<T>; pagination: ListPagination }) {
291
+ const { items, keyExtractor, gap = 47, followEnd, initialPosition, measurementKey } = props;
291
292
  if (!Number.isFinite(gap) || gap < 0) throw new Error("List gap must be finite and non-negative");
292
- const load = useAction(() => onLoadMore?.());
293
- const older = useAction(() => onLoadOlder?.());
294
293
  const committed = useRef<Records<T> | null>(null);
295
294
  const committedRequest = useRef<Request<T> | null>(null);
296
295
  const committedPlan = useRef<Plan<T> | null>(null);
@@ -388,14 +387,13 @@ export function CompiledList<T>({ plan, ...props }: ListProps<T> & { plan: Plan<
388
387
  if (timer !== undefined) clearTimeout(timer);
389
388
  };
390
389
  }, [request]);
390
+ const paginationProps = useListPaginationEvents(pagination, preparing || !records,
391
+ records?.data.length ?? 0, records?.data[0]?.key, records?.data.at(-1)?.key);
391
392
  if (outcome?.status === "error") throw outcome.error;
392
- if (!records) return createElement(ReactList<T>, props);
393
+ if (!records) return createElement(ReactList<T>, { ...props, pagination });
393
394
  const nativeProps = {
394
395
  items: records.data, keyField: "key", template: records.template, gap, followEnd, initialEnd: initialPosition === "end",
395
- hasMore: !preparing && hasMore && load.status !== "pending" && load.status !== "error",
396
- hasOlder: !preparing && hasOlder && older.status !== "pending" && older.status !== "error",
397
- onEndReached: !preparing && onLoadMore ? load.run : undefined,
398
- onStartReached: !preparing && onLoadOlder ? older.run : undefined,
396
+ ...paginationProps,
399
397
  onRowEvent(key: string, target: number, name: string, ...args: unknown[]) {
400
398
  const latest = events.current;
401
399
  const path = latest?.targets.get(target)?.[name];
@@ -432,11 +430,7 @@ export function CompiledList<T>({ plan, ...props }: ListProps<T> & { plan: Plan<
432
430
  if (typeof callback === "function") callback(...args);
433
431
  },
434
432
  };
435
- return createElement(Fragment, null,
436
- older.status === "error" && createElement(ListLoadError, { message: older.error.message, onRetry: older.run }),
437
- createElement("NativeList", nativeProps),
438
- load.status === "error" && createElement(ListLoadError, { message: load.error.message, onRetry: load.run }),
439
- );
433
+ return createElement("NativeList", nativeProps);
440
434
  }
441
435
 
442
436
  /** @internal Fixed native descriptions for Ink's own list components. */
package/src/native.ts CHANGED
@@ -34,6 +34,14 @@ Object.defineProperty(globalThis, "__inkReceive", {
34
34
  },
35
35
  });
36
36
 
37
+ Object.defineProperty(globalThis, "__inkResolveNativeResult", {
38
+ value: (id: number, value?: string, kind?: string, message?: string, retryable?: boolean) => {
39
+ listeners.get("result")?.(typeof value === "string"
40
+ ? { id, type: "result", value }
41
+ : { id, kind, message, retryable, type: "result" });
42
+ },
43
+ });
44
+
37
45
  onNativeMessage("result", message => {
38
46
  if (typeof message.id !== "number") throw new Error("Invalid native result ID");
39
47
  const request = pending.get(message.id);
package/src/playing.ts CHANGED
@@ -2,7 +2,7 @@ import { createElement, useRef } from "./react";
2
2
  import { Screen, ErrorState, type IconAsset } from "./index";
3
3
  import { useAction } from "./action";
4
4
  import { playbackController } from "./playback-binding";
5
- import { forward10Filled, forward30Filled, forward5Filled, pauseFilled, playArrowFilled, replay10Filled, replay30Filled, replay5Filled, skipNextFilled, skipPreviousFilled } from "./icons";
5
+ import { forward10Filled, forward15Filled, forward30Filled, forward5Filled, pauseFilled, playArrowFilled, replay10Filled, replay15Filled, replay30Filled, replay5Filled, skipNextFilled, skipPreviousFilled } from "./icons";
6
6
 
7
7
  type PlaybackAction = () => void | Promise<void>;
8
8
  export interface Playback {
@@ -17,7 +17,7 @@ export interface Playback {
17
17
 
18
18
  export type TransportControl = { onLongPress?: PlaybackAction; disabled?: boolean } & (
19
19
  | { kind: "track"; seconds?: never; icon?: never; onPress?: never }
20
- | { kind: "skip"; seconds: 5 | 10 | 30; icon?: never; onPress?: never }
20
+ | { kind: "skip"; seconds: 5 | 10 | 15 | 30; icon?: never; onPress?: never }
21
21
  | { kind: "custom"; icon: IconAsset; onPress: PlaybackAction; seconds?: never }
22
22
  );
23
23
  export type PlayingScreenProps = {
@@ -32,8 +32,8 @@ export type PlayingScreenProps = {
32
32
  actions?: readonly (({ icon: IconAsset; label?: never } | { label: string; icon?: never }) & { selected?: boolean; disabled?: boolean; onPress: PlaybackAction })[];
33
33
  };
34
34
 
35
- const replayIcons = { 5: replay5Filled, 10: replay10Filled, 30: replay30Filled };
36
- const forwardIcons = { 5: forward5Filled, 10: forward10Filled, 30: forward30Filled };
35
+ const replayIcons = { 5: replay5Filled, 10: replay10Filled, 15: replay15Filled, 30: replay30Filled };
36
+ const forwardIcons = { 5: forward5Filled, 10: forward10Filled, 15: forward15Filled, 30: forward30Filled };
37
37
 
38
38
  export function PlayingScreen({ image, preloadImages = [], title, onTitlePress, artists, playback, previous = { kind: "track" }, next = { kind: "track" }, actions = [] }: PlayingScreenProps) {
39
39
  const retry = useRef<PlaybackAction>(() => {});
@@ -0,0 +1,3 @@
1
+ declare module "@prefresh/utils" {
2
+ export function flush(): void;
3
+ }
@@ -0,0 +1,24 @@
1
+ import React from "preact/compat";
2
+ import { cloneElement, isValidElement } from "preact";
3
+ import { Activity, useEffect, useLayoutEffect, useSyncExternalStore, useImperativeHandle } from "./visibility";
4
+ import { useRef, useCallback, useLayoutEffect as commitLayoutEffect } from "preact/hooks";
5
+ const compatibleReact = /* @__PURE__ */ Object.assign({}, React, { Activity, cloneElement, isValidElement, useEffect, useLayoutEffect, useSyncExternalStore, useImperativeHandle, useEffectEvent });
6
+ export default compatibleReact;
7
+ export {
8
+ Children, Component, Fragment, PureComponent, StrictMode, Suspense, SuspenseList,
9
+ createContext, createElement, createFactory, createPortal, createRef,
10
+ findDOMNode, flushSync, forwardRef, hydrate, lazy, memo, render,
11
+ startTransition, unmountComponentAtNode, unstable_batchedUpdates,
12
+ useCallback, useContext, useDebugValue, useDeferredValue,
13
+ useId, useInsertionEffect, useMemo, useReducer,
14
+ useRef, useState, useTransition, version,
15
+ } from "preact/compat";
16
+ export { cloneElement, isValidElement } from "preact";
17
+ export { Activity, useEffect, useLayoutEffect, useSyncExternalStore, useImperativeHandle } from "./visibility";
18
+
19
+ /** Effect events retain their identity and read the latest committed callback. */
20
+ export function useEffectEvent<T extends (...args: never[]) => unknown>(callback: T): T {
21
+ const current = useRef(callback);
22
+ commitLayoutEffect(() => { current.current = callback; });
23
+ return useCallback(((...args: Parameters<T>) => current.current(...args)) as T, []);
24
+ }
package/src/react.ts CHANGED
@@ -1,6 +1,26 @@
1
- // Share the CommonJS React import so unused component modules contribute no wrapper code.
2
- export {
3
- Activity, Children, Fragment, createContext, createElement, isValidElement, memo,
4
- useCallback, useContext, useEffect, useLayoutEffect, useMemo, useRef, useState,
5
- useSyncExternalStore, type ComponentProps, type ReactElement, type ReactNode,
6
- } from "react";
1
+ import { createElement as element, createContext as context, isValidElement as valid, Fragment as fragment, Component, type ComponentChildren } from "preact";
2
+ import { useCallback, useContext as contextValue, useMemo, useRef, useState } from "preact/hooks";
3
+ import type * as React from "react";
4
+
5
+ // Keep the public React declarations while executing Preact core in Ink.
6
+ export const createElement = element as unknown as typeof React.createElement;
7
+ export const createContext = context as unknown as typeof React.createContext;
8
+ export const useContext = contextValue as unknown as typeof React.useContext;
9
+ export const isValidElement = valid as unknown as typeof React.isValidElement;
10
+ export const Fragment = fragment as unknown as typeof React.Fragment;
11
+ export { Children } from "preact/compat";
12
+ export { useCallback, useMemo, useRef, useState };
13
+ export { Activity, useEffect, useLayoutEffect, useSyncExternalStore } from "./visibility";
14
+ export type { ComponentProps, ComponentType, ReactElement, ReactNode } from "react";
15
+
16
+ // A single core component avoids compat memo's extra function-component layer.
17
+ export function memo<P extends object>(render: (props: P) => React.ReactNode) {
18
+ return class extends Component<P> {
19
+ shouldComponentUpdate(next: P) {
20
+ const previous = this.props;
21
+ const keys = Object.keys(next) as (keyof P)[];
22
+ return keys.length !== Object.keys(previous).length || keys.some(key => !Object.is(previous[key], next[key]));
23
+ }
24
+ render() { return render(this.props) as ComponentChildren; }
25
+ } as unknown as (props: P) => React.ReactNode;
26
+ }
package/src/refresh.ts ADDED
@@ -0,0 +1,29 @@
1
+ import "@prefresh/core";
2
+ import { flush } from "@prefresh/utils";
3
+
4
+ declare const self: { __PREFRESH__: {
5
+ register(type: unknown, id: string): void;
6
+ sign(type: unknown, key: string, forceReset: boolean, getCustomHooks: (() => unknown[]) | undefined, status: string): string;
7
+ } };
8
+
9
+ export const register = (type: unknown, id: string) => self.__PREFRESH__.register(type, id);
10
+ export const performReactRefresh = flush;
11
+
12
+ // Oxc emits React Refresh's signature protocol; translate it to Prefresh.
13
+ export function createSignatureFunctionForTransform() {
14
+ let savedType: unknown;
15
+ let savedKey = "";
16
+ let savedReset = false;
17
+ let savedHooks: (() => unknown[]) | undefined;
18
+ let status = "begin";
19
+ return (type?: unknown, key?: string, forceReset = false, getCustomHooks?: () => unknown[]) => {
20
+ if (type) {
21
+ savedType = type;
22
+ savedKey = key ?? "";
23
+ savedReset = forceReset;
24
+ savedHooks = getCustomHooks;
25
+ }
26
+ status = self.__PREFRESH__.sign(savedType, savedKey, savedReset, savedHooks, status);
27
+ return type;
28
+ };
29
+ }