lecodes-sdk 2.1.1 → 2.2.1

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/package.json CHANGED
@@ -35,7 +35,7 @@
35
35
  "prepublishOnly": "bun run build"
36
36
  },
37
37
  "dependencies": {
38
- "@letary/chisel": "^0.13.0"
38
+ "@letary/chisel": "^0.14.0"
39
39
  },
40
40
  "devDependencies": {
41
41
  "@types/bun": "^1.3.14",
@@ -45,7 +45,7 @@
45
45
  "marcidb-embedded": "^0.14.1",
46
46
  "sharp": "^0.35.2"
47
47
  },
48
- "version": "2.1.1",
48
+ "version": "2.2.1",
49
49
  "files": [
50
50
  "src",
51
51
  "dist",
package/prompts/core.md CHANGED
@@ -229,6 +229,7 @@ new WebSocket(url, headers?: Record<string, string>)
229
229
  // ===== STORAGE / DEVICE / MISC =====
230
230
 
231
231
  localStorage.getItem(key): string | null / .setItem(key, value) / .removeItem(key) // nothing else
232
+ signal<Line[]>([]).stored("cart") // a signal kept on the device under the key (JSON values): back on the next launch
232
233
 
233
234
  device.platform: "ios" | "android" | "web" // getter, no ()
234
235
  device.language: string // "en", "ru", …
package/prompts/ui.md CHANGED
@@ -125,17 +125,15 @@ UIScrollable(...children)
125
125
  // visible rows (plus a buffer) are mounted. Use it instead of UIScrollable + map() whenever the item count
126
126
  // is large, grows over time, or is unknown.
127
127
  UIVirtualizedList<T>({
128
+ data: () => T[], // the rows, read reactively: write the signal, the list follows (new array, keys matched)
128
129
  keyOf: (item: T) => string, // STABLE unique id per item (never the array index)
129
130
  render: (item: T) => UINodeChild, // builds one row; called lazily as rows enter the window
130
131
  estimatedHeight: number | ((item: T) => number), // px guess per row (real height measured after mount)
131
132
  overscan?: number, // extra px mounted above/below the viewport (default: one viewport)
132
133
  inverted?: boolean, // true = chat mode: starts scrolled to the end, append at bottom auto-scrolls
133
134
  })
134
- // Data is driven IMPERATIVELY (no diffing) — call these, never re-create the list:
135
- // .setData(items: T[]) // replace everything (diffed by key)
136
- // .append(...items) / .prepend(...items) // variadic; append = newest chat msg; prepend = history, no scroll jump
137
- // .update(...items) // re-render rows with the same keyOf(); unknown keys ignored
138
- // .removeByKey(...keys)
135
+ // Rows added at the end scroll a chat at its bottom; rows added before the first (history) move nothing; a row
136
+ // whose item is a new object re-renders. Never re-create the list.
139
137
  // .itemCount (getter) / .getItem(key)
140
138
  // .scrollTo(offset, animated?) / .scrollToKey(key, animated?) / .scrollToEnd(animated?) / .onScroll(cb(pos))
141
139
  // .onEndReached(thresholdPx: number, cb) // threshold FIRST and required; fires near the BOTTOM — load next page
@@ -193,6 +191,7 @@ UIInput() / UITextArea()
193
191
  // type: "text"|"password"|"search"|"phone"|"email"|"number"|"decimal"|"url"|"date"|"time"
194
192
  // enterKey: "done"|"go"|"next"|"search"|"send" (UIInput only) — return-key label
195
193
  // maxLength: number; autocapitalize: "none"|"words"|"sentences"|"characters"; autocorrect: boolean
194
+ // autofocus: boolean — takes the focus as its screen opens (a composer, a chat, a search; once, one per screen)
196
195
  // keyboardShrink: boolean (default true) — false: keyboard OVERLAYS the UI, no relayout (chat composers)
197
196
  // keyboardDismiss: boolean (default true) — false: taps never dismiss the keyboard (chat composers)
198
197
  // $focused: { borderColor, ..., duration? } — style while focused (reserved class)
package/src/chisel.ts CHANGED
@@ -48,6 +48,8 @@ export interface ChiselVocab {
48
48
  signals: {
49
49
  /** Calls that create a signal (the roots of chisel's signal-type inference). */
50
50
  factories: string[]
51
+ /** Methods of a signal that return it (`signal(0).stored("key")`): a call of one on a signal is a signal. */
52
+ chain: string[]
51
53
  /** The property a signal is read through. */
52
54
  value: string
53
55
  }
@@ -131,8 +133,8 @@ export const chiselVocab: ChiselVocab = {
131
133
  "append", "insert", "remove", "setContent", "keepAlive", "url",
132
134
  "detents", "dismissible", "transition", "setSourceRect",
133
135
  "push", "pop", "popToRoot", "replace", "select",
134
- // UITabs / UIVirtualizedList / the native views (NativeView, CameraView, MapView, QRScanner)
135
- "badge", "setData", "update", "prepend", "removeByKey",
136
+ // UITabs / the native views (NativeView, CameraView, MapView, QRScanner)
137
+ "badge", "setData",
136
138
  "on", "off", "onReady", "onError", "onTap", "onMove", "onScan",
137
139
  "flyTo", "jumpTo", "fitPoints", "setPadding", "setUserLocation",
138
140
  ],
@@ -142,6 +144,7 @@ export const chiselVocab: ChiselVocab = {
142
144
  },
143
145
  signals: {
144
146
  factories: [ "signal", "computed" ],
147
+ chain: [ "stored" ],
145
148
  value: "value",
146
149
  },
147
150
  fusion: {
@@ -17,6 +17,8 @@
17
17
  // fallback for engines without it (then stale subscribers just live until the run ends —
18
18
  // every run executes in a fresh JSRuntime anyway).
19
19
 
20
+ import { localStorage } from "../runtime/storage"
21
+
20
22
  interface WeakRefLike<T extends object> { deref(): T | undefined }
21
23
 
22
24
  const WeakRefImpl: new <T extends object>(target: T) => WeakRefLike<T> =
@@ -150,6 +152,12 @@ export interface Signal<T> {
150
152
  value: T,
151
153
  /** Read without subscribing the current effect/computed. */
152
154
  peek(): T,
155
+ /**
156
+ * Kept on the device under `key`: what the last run left there replaces the initial value (when
157
+ * it is there and reads as JSON), and every write is saved. JSON values only — a date, a class
158
+ * instance or a function does not survive the round trip. `signal<Line[]>([]).stored("cart")`.
159
+ */
160
+ stored(key: string): Signal<T>,
153
161
  }
154
162
 
155
163
  export interface Computed<T> {
@@ -161,6 +169,7 @@ export interface Computed<T> {
161
169
  class SignalImpl<T> {
162
170
  private _dep = new Dep()
163
171
  private _v: T
172
+ private _key?: string
164
173
 
165
174
  constructor(value: T) {
166
175
  this._v = value
@@ -174,12 +183,31 @@ class SignalImpl<T> {
174
183
  set value(next: T) {
175
184
  if (Object.is(next, this._v)) return
176
185
  this._v = next
186
+ if (this._key !== undefined) this._save()
177
187
  this._dep._notify()
178
188
  }
179
189
 
180
190
  peek(): T {
181
191
  return this._v
182
192
  }
193
+
194
+ stored(key: string): Signal<T> {
195
+ this._key = key
196
+ let raw: string | null = null
197
+ try { raw = localStorage.getItem(key) } catch { /* a host without storage: the value lives for this run */ }
198
+ if (raw !== null) {
199
+ try { this.value = JSON.parse(raw) as T } catch { /* not what this app wrote: the initial value stands */ }
200
+ }
201
+ return this
202
+ }
203
+
204
+ // Saved as it is written, so what the device keeps never lags behind the screen (no effect, no flush).
205
+ private _save(): void {
206
+ try {
207
+ if (this._v === undefined) localStorage.removeItem(this._key!)
208
+ else localStorage.setItem(this._key!, JSON.stringify(this._v))
209
+ } catch { /* storage-less host, or a value JSON cannot carry: the screen is right, the device forgets */ }
210
+ }
183
211
  }
184
212
 
185
213
  class ComputedImpl<T> implements Computation {
package/src/inject.ts CHANGED
@@ -43,6 +43,8 @@ export { Net, NetPlayer, Replicated, NetEntity, type NetMessage, type NetRole, t
43
43
  // App-backend transport: the compiler-generated stubs of `*.server.ts` modules call these; user code
44
44
  // only ever imports its server functions (docs/backend-plan.md §4).
45
45
  export { __rpc, __channel, __serverOnly, RpcError } from "./runtime/rpc"
46
+ // What a screen asks of the server, as reactive state: a request a screen follows, an action with its own busy / error.
47
+ export { resource, action, dataOf, failureOf, type Loaded, type Failure, type Resource, type Action } from "./runtime/resource"
46
48
  export type { ChannelSubscription } from "./server/channel"
47
49
  export type { StoredFile, StoredImage } from "./server/files/models"
48
50
  export { AudioPlayer, VideoPlayer } from "./runtime/media"
@@ -0,0 +1,96 @@
1
+ // What a screen asks of the app's server, as reactive state: a `resource` is a request a screen
2
+ // follows (loading, ready, failed — ONE value), an `action` is a call with its own busy / error —
3
+ // no try around a call, no flags by hand. Both take the compiled server stubs of ./rpc.ts, so a
4
+ // failure is an RpcError: its status is the server's ApiError's, 0 when the request never reached
5
+ // a server (no network, no backend) — `offline`.
6
+
7
+ import { effect, signal, type Computed } from "../core/signals"
8
+
9
+ /** Why a call failed: the server's refusal in its words — or `offline`, the request never reached
10
+ * it (then `message` is the transport's note, not something to show; say it in your own words). */
11
+ export type Failure = { readonly message: string, readonly offline: boolean }
12
+
13
+ /** What was asked of the server, at any moment: one value a screen draws in its three states. */
14
+ export type Loaded<T> =
15
+ | { readonly state: "loading" }
16
+ | { readonly state: "ready", readonly data: T }
17
+ | ({ readonly state: "failed" } & Failure)
18
+
19
+ /** The data once it is there; `otherwise` until then. */
20
+ export const dataOf = <T>(loaded: Loaded<T>, otherwise: T): T => loaded.state === "ready" ? loaded.data : otherwise
21
+
22
+ /** The failure, or null while there is none. */
23
+ export const failureOf = <T>(loaded: Loaded<T>): Failure | null => loaded.state === "failed" ? loaded : null
24
+
25
+ const failure = (error: unknown): Failure => ({
26
+ message: error instanceof Error ? error.message : String(error),
27
+ offline: (error as { status?: unknown } | null)?.status === 0,
28
+ })
29
+
30
+ export interface Resource<T> {
31
+ /** The request now — read in an effect, a computed or a binding, the screen follows it. */
32
+ readonly value: Loaded<T>
33
+ /** Read without subscribing. */
34
+ peek(): Loaded<T>
35
+ /** Ask again. What is shown stays until the answer. */
36
+ reload(): void
37
+ }
38
+
39
+ /**
40
+ * What `load` answers, as a `Loaded` signal: asked at once, again when a signal `load` read changes
41
+ * and on `reload()`. An answer that arrives after a newer request was made is dropped. While the
42
+ * data is asked for again, what is there stays. With `initial` — rows the bundle carries — the data
43
+ * is ready from the start and the server's answer replaces it; a failure then leaves it as it is,
44
+ * since what is shown is real.
45
+ */
46
+ export function resource<T>(load: () => Promise<T>, options: { initial?: T } = {}): Resource<T> {
47
+ const loaded = signal<Loaded<T>>(options.initial !== undefined ? { state: "ready", data: options.initial } : { state: "loading" })
48
+ const asked = signal(0)
49
+ let latest = 0
50
+ effect(() => {
51
+ asked.value
52
+ const mine = ++latest
53
+ let request: Promise<T>
54
+ try { request = load() } catch (error) { request = Promise.reject(error) }
55
+ if (loaded.peek().state !== "ready") loaded.value = { state: "loading" }
56
+ request.then(
57
+ (data) => { if (mine === latest) loaded.value = { state: "ready", data } },
58
+ (error) => { if (mine === latest && !(options.initial !== undefined && loaded.peek().state === "ready")) loaded.value = { state: "failed", ...failure(error) } },
59
+ )
60
+ })
61
+ return {
62
+ get value() { return loaded.value },
63
+ peek: () => loaded.peek(),
64
+ reload: () => { asked.value = asked.peek() + 1 },
65
+ }
66
+ }
67
+
68
+ export type Action<A extends unknown[], R> = ((...args: A) => Promise<R | null>) & {
69
+ /** True while a call runs; a call made meanwhile is ignored (it resolves to null). */
70
+ readonly busy: Computed<boolean>
71
+ /** Why the last call failed; null when it did not. Cleared as a call starts. */
72
+ readonly error: Computed<Failure | null>
73
+ }
74
+
75
+ /**
76
+ * Something the person does that the server must carry out. Resolves to what the server answered,
77
+ * or null when the call failed (then `error` says why) or was ignored while busy.
78
+ */
79
+ export function action<A extends unknown[], R>(run: (...args: A) => Promise<R>): Action<A, R> {
80
+ const busy = signal(false)
81
+ const error = signal<Failure | null>(null)
82
+ const call = async (...args: A): Promise<R | null> => {
83
+ if (busy.peek()) return null
84
+ busy.value = true
85
+ error.value = null
86
+ try {
87
+ return await run(...args)
88
+ } catch (thrown) {
89
+ error.value = failure(thrown)
90
+ return null
91
+ } finally {
92
+ busy.value = false
93
+ }
94
+ }
95
+ return Object.assign(call, { busy, error })
96
+ }
@@ -258,6 +258,8 @@ export interface Query<T extends ModelTypes, Sel = T["scalars"]> extends Promise
258
258
  aggregate<A extends T["aggregate"]>(query: A): Op<AggregateResult<T["model"], A>> & Sub<T["name"], NoInfer<A>>
259
259
 
260
260
  insert(data: T["insert"]): Op<T["id"]>
261
+ /** The new row by `select`'s shape — its defaults, dates and files as stored: one call, nothing to read back. */
262
+ insert<S extends T["query"]>(data: T["insert"], options: { select: S }): Promise<Rows<T, S>>
261
263
  update(id: IdArg<T["id"]>, data: T["update"]): Op<void>
262
264
  /** Applies `data` to every row the chain's `where` matches (all rows without one); resolves to the number of rows. */
263
265
  updateMany(data: T["update"]): Op<number>
@@ -454,7 +456,13 @@ export function createQueryLayer(options: QueryLayerOptions): { op: (descriptor:
454
456
  return Object.assign(op({ model, action: "aggregate", query: q }), { __select: q });
455
457
  },
456
458
 
457
- insert: (data: any) => op({ model, action: "insert", data: options.prepareInsert ? options.prepareInsert(model, data) : data }),
459
+ insert: (data: any, extra?: { select?: Record<string, any> }) => {
460
+ const inserted = op({ model, action: "insert", data: options.prepareInsert ? options.prepareInsert(model, data) : data });
461
+ if (!extra?.select) return inserted;
462
+ // the row as stored, read back by the id the engine answered — a plain promise: a read is not a $transaction member
463
+ return inserted.then((id: any) => run({ model, action: "findFirst", query: resolveShape(model, { ...extra.select, $where: id }) }))
464
+ .then((row: any) => { if (row === null) throw new Error(`marcidb: ${model}: the row just inserted is gone`); return row; });
465
+ },
458
466
  update: (id: any, data: any) => op({ model, action: "update", id: idObject(model, id), data }),
459
467
  // `updateMany(data)`; the deprecated `updateMany(query, data)` form is told apart by arity
460
468
  updateMany: (a: any, b?: any) => (b === undefined
@@ -9,7 +9,7 @@
9
9
 
10
10
  import type { Field, FieldMeta } from "./fields"
11
11
  import type { IdentityPublicFields, SessionPublicFields } from "../auth/models"
12
- import type { FileField, StoredFile, UploadedFile as File } from "../files/models"
12
+ import type { FileField, FileWrite, StoredFile } from "../files/models"
13
13
  import type {
14
14
  CompareNumValue, CompareRefListValue, CompareRefValue, CompareStrValue, CompareValue, CustomSearchValue,
15
15
  FullTextSearch, JsonCondition, JsonPathWhere, Op, PrimitiveListUpdate, Query as MarciQuery, RefListUpdate,
@@ -72,7 +72,7 @@ type FieldInsert<S, Fld, M extends FieldMeta = MetaOf<Fld>> =
72
72
  M["kind"] extends "one" ? Nullable<M, Id<S, ModelName<S, Fld>>>
73
73
  : M["kind"] extends "many" | "list" ? Id<S, ModelName<S, Fld>>[]
74
74
  : M["kind"] extends "struct" ? Nullable<M, StructInsert<S, StructFieldsOf<Fld>>>
75
- : M["kind"] extends "file" ? Nullable<M, Arr<M, File>> // a new row takes uploads
75
+ : M["kind"] extends "file" ? Nullable<M, Arr<M, FileWrite>> // a new row takes uploads
76
76
  : Nullable<M, Arr<M, TsOf<Fld>>>
77
77
 
78
78
  type RequiredKeys<F extends Fields> = {
@@ -99,7 +99,7 @@ type FieldUpdate<S, Fld, M extends FieldMeta = MetaOf<Fld>> =
99
99
  : M["kind"] extends "list" ? RefListUpdateOrdered<Id<S, ModelName<S, Fld>>>
100
100
  : M["kind"] extends "struct" ? RefUpdateStruct<StructInsert<S, StructFieldsOf<Fld>>, UpdateOf<S, StructFieldsOf<Fld>>> | null
101
101
  // an upload replaces; the stored file the field holds, handed back, keeps it; null / a shorter list deletes
102
- : M["kind"] extends "file" ? Nullable<M, Arr<M, File | StoredFile>>
102
+ : M["kind"] extends "file" ? Nullable<M, Arr<M, FileWrite | StoredFile>>
103
103
  : M["array"] extends true ? TsOf<Fld>[] | PrimitiveListUpdate<TsOf<Fld>>
104
104
  : Nullable<M, TsOf<Fld> | (IsNum<M> extends true ? UpdateNumValue : never)>
105
105
 
@@ -43,6 +43,11 @@ export type StoredImage = StoredFile & { width: number, height: number }
43
43
  */
44
44
  export type UploadedFile = { readonly name: string, readonly size: number, readonly type: string }
45
45
 
46
+ /** What a file field is written with. A project's own type check sees the endpoint's `File` as the
47
+ * app's (global.d.ts: a name and a size — the type is read from the bytes on arrival), so the write
48
+ * asks for no more; what arrives is an `UploadedFile`. */
49
+ export type FileWrite = { readonly name: string, readonly size: number }
50
+
46
51
  /** One `t.file()` field of the schema; `image` = it is a `t.image()`, with its rule. */
47
52
  export type FileField = { model: string, key: string, array: boolean, owner: string, image?: ImageRule }
48
53
 
package/src/ui/UIInput.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { type ElementStyle, type DrawableStyle, Element, type TextStyle, type Color, type BaseStyle, type UIElementBase } from "./UINode";
2
2
  import { TREE_FLAG_BLUR, TREE_FLAG_CHANGE, TREE_FLAG_FOCUS, TREE_FLAG_SUBMIT, tree } from "./tree"
3
+ import { registerAutofocus } from "./autofocus"
3
4
 
4
5
  type InputStyle = {
5
6
  placeholder?: string,
@@ -32,6 +33,13 @@ type InputStyle = {
32
33
  * still work in both modes. Scrolling never dismisses the keyboard on any setting.
33
34
  */
34
35
  keyboardDismiss?: boolean,
36
+ /**
37
+ * Takes the focus when its screen opens — the keyboard is up from the first moment. For a
38
+ * screen made for typing: a composer, a chat, a search, a one-field form; not the first field
39
+ * of a long form. Once per input (a screen shown again does not take the keyboard back); one
40
+ * per screen. The SDK's own behaviour over `focus()`: a design tool shows the screen as it opens.
41
+ */
42
+ autofocus?: boolean,
35
43
  /** Hard cap on the text length — enforced by the host while typing/pasting. */
36
44
  maxLength?: number,
37
45
  /** Auto-capitalization hint; omitted = platform default. */
@@ -110,9 +118,11 @@ class InputElement<T extends "input" | "textarea"> extends Element<T> {
110
118
  }
111
119
 
112
120
  // `phone` is SDK-surface only — hosts (and the web's verbatim `el.type`) keep HTML's `tel` as
113
- // the wire format, so the rename never reaches them.
121
+ // the wire format, so the rename never reaches them. `autofocus` is the SDK's too (./autofocus.ts):
122
+ // it never reaches a host.
114
123
  _mapStyle(style: any): void {
115
124
  if (style.type === "phone") style.type = "tel"
125
+ if ("autofocus" in style) { registerAutofocus(this, !!style.autofocus); delete style.autofocus }
116
126
  }
117
127
 
118
128
  onChange (callback: (value: string) => void): this {
package/src/ui/UINode.ts CHANGED
@@ -395,6 +395,7 @@ const handler: ProxyHandler<any> = {
395
395
  if (el._mapStyle !== undefined) {
396
396
  const mapped = { [key]: value }
397
397
  el._mapStyle(mapped)
398
+ if (!(key in mapped)) return true // the SDK's own key (an input's autofocus): nothing for the host
398
399
  value = mapped[key]
399
400
  }
400
401
  const wire = wireValue(key, value)
@@ -3,6 +3,7 @@ import { _bumpNavEpoch, _setCurrent, Presentable, type DestTuple, type DismissOp
3
3
  import { _transitionId, TRANSITION_NONE } from "./transitions"
4
4
  import { ContainerElement, buildUI, type BaseStyle, type ContainerStyle, type DrawableStyle, type PaddingStyle, type UIChildArg, type UIContainerBase, type UINodeChild, type ChildrenFn, } from "./UINode"
5
5
  import { TREE_DEST_SCREEN, TREE_FLAG_BACK, TREE_FLAG_INTERACTIVE, TREE_FLAG_KEEP_ALIVE, pin, tree } from "./tree"
6
+ import { autofocusIn } from "./autofocus"
6
7
 
7
8
  export type UIScreenStyle = ContainerStyle & DrawableStyle & PaddingStyle & BaseStyle
8
9
 
@@ -92,7 +93,7 @@ export class ScreenElement extends ContainerElement<"screen"> {
92
93
  return this
93
94
  }
94
95
  /** @internal presentation lifecycle from the runtime (also a pager's page open / close). */
95
- _emitOpen(): void { for (const cb of this.ol.slice()) cb() }
96
+ _emitOpen(): void { autofocusIn(this._h.id); for (const cb of this.ol.slice()) cb() }
96
97
  /** @internal */
97
98
  _emitClose(): void { for (const cb of this.cl.slice()) cb() }
98
99
 
@@ -1,3 +1,4 @@
1
+ import { createBinding } from "../core/signals"
1
2
  import { Element, type Color, type DrawableStyle, type ElementStyle, type UIElementBase, type UINodeChild } from "./UINode"
2
3
  import { TREE_FLAG_REFRESH, TREE_FLAG_SCROLL, TREE_FLAG_VLIST_END, TREE_FLAG_VLIST_START, pin, tree, unpin } from "./tree"
3
4
 
@@ -10,6 +11,11 @@ export type UIVirtualizedListStyle = DrawableStyle & ElementStyle & {
10
11
  }
11
12
 
12
13
  export interface VListConfig<T> {
14
+ /** The rows, read reactively: the list follows the signals this reads, the way a container's
15
+ * function children do. Rows are told apart by `keyOf`; a row whose item is another object
16
+ * re-renders. Appended rows and rows added at the start are told from a rewrite (a chat at
17
+ * its bottom follows what is appended; older history above moves nothing). */
18
+ data: () => T[]
13
19
  /** Stable unique key of an item. */
14
20
  keyOf: (item: T) => string
15
21
  /** Subtree factory — called when the item enters the window; the subtree lives until it
@@ -27,22 +33,10 @@ export interface VListConfig<T> {
27
33
  }
28
34
 
29
35
  /** A windowed (virtualized) list: only the items near the viewport are mounted — for long feeds
30
- * and chats. Items are keyed; feed it via `setData`/`append`/`prepend`. */
36
+ * and chats. Fed by `data`, a function over signals. */
31
37
  export interface UIVirtualizedList<T = unknown> extends UIElementBase<UIVirtualizedListStyle> {
32
38
  readonly type: "vlist"
33
39
 
34
- /** Replace the data wholesale — vanished keys are unmounted by the window sync. */
35
- setData(items: T[]): this
36
- /** Append items to the end (auto-scrolls when `inverted` and resting at the bottom). */
37
- append(...items: T[]): this
38
- /** Insert at the start WITHOUT a scroll jump (the runtime compensates the offset). */
39
- prepend(...items: T[]): this
40
- /** Update items with the same keys: mounted ones re-render; the cached height persists until
41
- * re-measured. */
42
- update(...items: T[]): this
43
- /** Remove items by key. */
44
- removeByKey(...keys: string[]): this
45
-
46
40
  /** Number of data items (mounted or not). */
47
41
  readonly itemCount: number
48
42
  /** The data item under `key`, if any. */
@@ -72,6 +66,7 @@ export class VListElement<T> extends Element<"vlist"> implements UIVirtualizedLi
72
66
  private readonly strl: (() => void)[] = [] // startReached
73
67
  private _refreshCallback?: () => void | Promise<void>
74
68
 
69
+ private keys: string[] = []
75
70
  private items = new Map<string, T>()
76
71
  private mounted = new Map<string, any>()
77
72
  private keyOf: (item: T) => string
@@ -95,77 +90,61 @@ export class VListElement<T> extends Element<"vlist"> implements UIVirtualizedLi
95
90
  : () => config.estimatedHeight as number
96
91
  this._overscan = config.overscan ?? -1
97
92
  this._inverted = config.inverted ?? false
93
+ // The data binding is the element's (it goes with it); its first run waits a microtask so the
94
+ // edge thresholds chained after the constructor (`.onEndReached(…)`) are in the runtime's list
95
+ // state, which is created on the first write.
96
+ Promise.resolve().then(() => { if (this._h.id !== 0) createBinding(this, "data", () => this._apply(config.data())) })
98
97
  }
99
98
 
100
99
  // The runtime's list state is created on the first data write — after the app had its chance to
101
- // set the edge thresholds (onEndReached / onStartReached come before setData in practice).
100
+ // set the edge thresholds.
102
101
  private _ensure(): void {
103
102
  if (this._inited || this._h.id === 0) return
104
103
  this._inited = true
105
104
  tree().vlistInit(this._h.id, this._overscan, this._inverted, this._startThreshold, this._endThreshold)
106
105
  }
107
106
 
108
- private register(items: T[]): [string[], number[]] {
107
+ /** The data as it is now against what the runtime holds: rows added at the end or at the start
108
+ * are inserts (the runtime anchors the scroll: a chat follows an append, history above moves
109
+ * nothing), rows gone are removals, anything else a rewrite of the keys (measured heights stay). */
110
+ private _apply(next: T[]): void {
111
+ const prevKeys = this.keys
112
+ const prevItems = this.items
109
113
  const keys: string[] = []
110
114
  const estimates: number[] = []
111
- for (const item of items) {
115
+ const items = new Map<string, T>()
116
+ for (const item of next) {
112
117
  const k = this.keyOf(item)
113
- this.items.set(k, item)
118
+ if (items.has(k)) continue // a repeated key: the first row stands
119
+ items.set(k, item)
114
120
  keys.push(k)
115
121
  estimates.push(this.estimate(item))
116
122
  }
117
- return [keys, estimates]
118
- }
119
-
120
- setData(items: T[]): this {
121
- const prevItems = new Map(this.items)
122
- const prevMounted = [...this.mounted.keys()]
123
- this.items.clear()
124
- const [keys, estimates] = this.register(items)
125
- this._ensure()
126
- if (this._h.id !== 0) {
127
- tree().vlistSetKeys(this._h.id, keys, estimates)
128
- for (const k of prevMounted) {
129
- if (this.mounted.has(k) && this.items.get(k) !== prevItems.get(k)) {
130
- tree().vlistInvalidate(this._h.id, k)
131
- }
132
- }
133
- }
134
- return this
135
- }
136
-
137
- append(...items: T[]): this {
138
- const [keys, estimates] = this.register(items)
139
- this._ensure()
140
- if (this._h.id !== 0) tree().vlistInsertKeys(this._h.id, -1, keys, estimates)
141
- return this
142
- }
143
-
144
- prepend(...items: T[]): this {
145
- const [keys, estimates] = this.register(items)
123
+ this.keys = keys
124
+ this.items = items
146
125
  this._ensure()
147
- if (this._h.id !== 0) tree().vlistInsertKeys(this._h.id, 0, keys, estimates)
148
- return this
149
- }
150
-
151
- update(...items: T[]): this {
152
- for (const item of items) {
153
- const k = this.keyOf(item)
154
- if (!this.items.has(k)) continue
155
- this.items.set(k, item)
156
- if (this._h.id !== 0 && this.mounted.has(k)) tree().vlistInvalidate(this._h.id, k)
126
+ const id = this._h.id
127
+ if (id === 0) return
128
+ const n = prevKeys.length, m = keys.length
129
+ const same = (from: number) => { for (let i = 0; i < n; i++) if (keys[from + i] !== prevKeys[i]) return false; return true }
130
+ if (n === 0 || m === 0) {
131
+ tree().vlistSetKeys(id, keys, estimates)
132
+ } else if (m >= n && same(0)) {
133
+ if (m > n) tree().vlistInsertKeys(id, -1, keys.slice(n), estimates.slice(n)) // the same keys: nothing to tell
134
+ } else if (m > n && same(m - n)) {
135
+ tree().vlistInsertKeys(id, 0, keys.slice(0, m - n), estimates.slice(0, m - n))
136
+ } else {
137
+ // removals only: the new keys are the old ones in order, some left out
138
+ const gone: string[] = []
139
+ let j = 0
140
+ for (const k of prevKeys) { if (j < m && keys[j] === k) j++; else gone.push(k) }
141
+ if (m < n && j === m) tree().vlistRemoveKeys(id, gone)
142
+ else tree().vlistSetKeys(id, keys, estimates)
157
143
  }
158
- return this
159
- }
160
-
161
- removeByKey(...keys: string[]): this {
162
- const gone: string[] = []
163
- for (const k of keys) {
164
- if (!this.items.delete(k)) continue
165
- gone.push(k)
144
+ // a mounted row whose item is another object re-renders; the others pick theirs up when they re-enter
145
+ for (const k of this.mounted.keys()) {
146
+ if (items.has(k) && prevItems.has(k) && items.get(k) !== prevItems.get(k)) tree().vlistInvalidate(id, k)
166
147
  }
167
- if (this._h.id !== 0 && gone.length > 0) tree().vlistRemoveKeys(this._h.id, gone)
168
- return this
169
148
  }
170
149
 
171
150
  get itemCount(): number { return this.items.size }
@@ -233,8 +212,8 @@ export class VListElement<T> extends Element<"vlist"> implements UIVirtualizedLi
233
212
  }
234
213
  }
235
214
 
236
- /** Create a virtualized list from a config (`keyOf` + `render` + `estimatedHeight` required),
237
- * then feed it via `setData`/`append`/`prepend`. Give it a size (`flexGrow: 1` as a screen body). */
215
+ /** Create a virtualized list from a config (`data` + `keyOf` + `render` + `estimatedHeight`
216
+ * required). Give it a size (`flexGrow: 1` as a screen body). */
238
217
  export function UIVirtualizedList<T>(config: VListConfig<T>): UIVirtualizedList<T> {
239
218
  return new VListElement<T>(config)
240
219
  }
@@ -0,0 +1,54 @@
1
+ // Autofocus: an input that takes the focus when its screen opens (`autofocus: true` in its style)
2
+ // — a composer, a chat, a search, a one-field form open with the keyboard. The behaviour is the
3
+ // SDK's over the hosts' `focus()`: no host knows the key. An input registers here when the key is
4
+ // set; a screen's open (UIScreen._emitOpen, a pager's page too) asks for the one inside it. Once
5
+ // per input: a screen shown again (uncovered, its tab chosen again) does not take the keyboard
6
+ // back. Two on one screen is a mistake the console says; the first keeps it. The registry holds
7
+ // the inputs weakly: a screen that was dropped goes with its input.
8
+ import { tree } from "./tree"
9
+
10
+ interface WeakRefLike<T extends object> { deref(): T | undefined }
11
+ const WeakRefImpl: new <T extends object>(target: T) => WeakRefLike<T> =
12
+ typeof WeakRef !== "undefined" ? WeakRef : class StrongRef<T extends object> {
13
+ private _t: T
14
+ constructor(target: T) { this._t = target }
15
+ deref(): T | undefined { return this._t }
16
+ }
17
+
18
+ type Focusable = { readonly _h: { readonly id: number }, focus(): void, _autofocusDone?: boolean }
19
+
20
+ const registry = new Set<WeakRefLike<Focusable>>()
21
+ const refOf = (el: Focusable): WeakRefLike<Focusable> | undefined => { for (const ref of registry) if (ref.deref() === el) return ref; return undefined }
22
+
23
+ /** @internal The input asks for (or gives up) the focus at its screen's open. */
24
+ export const registerAutofocus = (el: Focusable, on: boolean): void => {
25
+ const ref = refOf(el)
26
+ if (on) { if (!ref) registry.add(new WeakRefImpl(el)) }
27
+ else if (ref) registry.delete(ref)
28
+ }
29
+
30
+ const within = (id: number, rootId: number): boolean => {
31
+ const t = tree()
32
+ let p = t.parent(id)
33
+ for (let guard = 0; p !== 0 && guard < 10_000; guard++) {
34
+ if (p === rootId) return true
35
+ p = t.parent(p)
36
+ }
37
+ return false
38
+ }
39
+
40
+ /** @internal A screen (or page) opened: the autofocus input inside it takes the focus, once. */
41
+ export const autofocusIn = (rootId: number): void => {
42
+ if (registry.size === 0) return
43
+ let taken: Focusable | null = null
44
+ for (const ref of [...registry]) {
45
+ const el = ref.deref()
46
+ if (!el || el._h.id === 0) { registry.delete(ref); continue }
47
+ if (el._autofocusDone || !within(el._h.id, rootId)) continue
48
+ if (taken) { console.warn("autofocus: two inputs on one screen ask for it — the first keeps it"); continue }
49
+ taken = el
50
+ }
51
+ if (!taken) return
52
+ taken._autofocusDone = true
53
+ taken.focus()
54
+ }
package/src/version.ts CHANGED
@@ -4,7 +4,7 @@
4
4
  // same manifest). Its MAJOR is the bundle ↔ runtime contract: a bundle's `// sdk:` header line
5
5
  // and a host's embedded version must agree on the major for the host to run the bundle; minor
6
6
  // and patch never gate anything (features are detected, never versioned).
7
- export const SDK_VERSION = "2.1.1"
7
+ export const SDK_VERSION = "2.2.1"
8
8
 
9
9
  /** The major of a semver string, or null when it is not one. */
10
10
  export const sdkMajor = (version: string | null | undefined): number | null => {