@solidrt/core 0.0.55 → 0.0.56

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/text-input.ts CHANGED
@@ -214,6 +214,9 @@ export type EditorLine = {
214
214
  /** The caret's box in content coordinates (before scroll). */
215
215
  export type CaretRect = { x: number; y: number; height: number }
216
216
 
217
+ /** One line's slice of a selection highlight, in content coordinates (before scroll). */
218
+ export type SelectionRect = { x: number; y: number; width: number; height: number }
219
+
217
220
  export type TextEditorLayout = {
218
221
  lines(): EditorLine[]
219
222
  caret(): CaretRect
@@ -221,6 +224,8 @@ export type TextEditorLayout = {
221
224
  caretLine(): number
222
225
  /** The caret position (grapheme boundary) on line `line` nearest to content x. */
223
226
  offsetAtX(line: number, x: number): number
227
+ /** Highlight boxes for the anchor..focus range, one per touched line; empty when collapsed. */
228
+ selectionRects(anchor: number, focus: number): SelectionRect[]
224
229
  /** Index of the line at content y (clamped to the first/last line). */
225
230
  lineAtY(y: number): number
226
231
  /** The caret position one grapheme left/right of `offset`; a break sequence is one step. For createTextBuffer's `step`. */
@@ -322,18 +327,22 @@ export function createTextEditorLayout(
322
327
  }
323
328
  let caretLine = createMemo(() => lineOf(input().caret))
324
329
 
325
- // The caret sits at the last stop at or before its offset (an offset inside
326
- // a grapheme, e.g. from a controlled value, snaps back).
327
- let caret = createMemo((): CaretRect => {
328
- let offset = input().caret
329
- let index = caretLine()
330
- let line = lines()[index]!
330
+ // The x of the last stop at or before `offset` on line `index` (an offset
331
+ // inside a grapheme, e.g. from a controlled value, snaps back).
332
+ let xAt = (index: number, offset: number): number => {
331
333
  let x = 0
332
334
  for (let stop of lineStops(index)) {
333
335
  if (stop.offset > offset) break
334
336
  x = stop.x
335
337
  }
336
- return { x, y: line.y, height: line.height }
338
+ return x
339
+ }
340
+
341
+ let caret = createMemo((): CaretRect => {
342
+ let offset = input().caret
343
+ let index = caretLine()
344
+ let line = lines()[index]!
345
+ return { x: xAt(index, offset), y: line.y, height: line.height }
337
346
  })
338
347
 
339
348
  // Only positions that show on this line are candidates: a boundary offset
@@ -352,6 +361,32 @@ export function createTextEditorLayout(
352
361
  return best
353
362
  }
354
363
 
364
+ // One box per line the range touches, offsets snapped to stops like the
365
+ // caret. A line the selection continues past (every one but the last)
366
+ // highlights to its ink end plus a space width, so the selected break - a
367
+ // newline, or the wrap the range crosses - is visible, and a selected empty
368
+ // line shows as that sliver alone. The last line ends at the focus stop; a
369
+ // range ending on a wrap boundary owns a zero-width box there (the boundary
370
+ // offset displays on the next line) and drops it.
371
+ let selectionRects = (anchor: number, focus: number): SelectionRect[] => {
372
+ let start = Math.min(anchor, focus)
373
+ let end = Math.max(anchor, focus)
374
+ if (start >= end) return []
375
+ let ls = lines()
376
+ let first = lineOf(start)
377
+ let last = lineOf(end)
378
+ let breakWidth = measureText(" ", input().font).width
379
+ let out: SelectionRect[] = []
380
+ for (let i = first; i <= last; i++) {
381
+ let line = ls[i]!
382
+ let x = i === first ? xAt(i, start) : 0
383
+ let width = i === last ? xAt(i, end) - x : Math.max(line.width - x, 0) + breakWidth
384
+ if (width <= 0) continue
385
+ out.push({ x, y: line.y, width, height: line.height })
386
+ }
387
+ return out
388
+ }
389
+
355
390
  let lineAtY = (y: number): number => {
356
391
  let ls = lines()
357
392
  let index = 0
@@ -399,7 +434,7 @@ export function createTextEditorLayout(
399
434
  setScrollY(follow(scrollY(), c.y, c.height, vh, contentHeight))
400
435
  })
401
436
 
402
- return { lines, caret, caretLine, offsetAtX, lineAtY, step, scrollX, scrollY }
437
+ return { lines, caret, caretLine, offsetAtX, selectionRects, lineAtY, step, scrollX, scrollY }
403
438
  }
404
439
 
405
440
  // Wrap units wider than `width` (through their glued pieces) split into one
package/src/transform.ts CHANGED
@@ -26,14 +26,18 @@ const ENGAGE = 0.6
26
26
  const QUIET_SMOOTH = 0.19
27
27
 
28
28
  export type TransformDelta = {
29
- /** Focal-point movement since the previous frame, logical px. */
29
+ /** Focal-point movement since the previous frame, in the handler node's
30
+ * PARENT frame (the frame its own x/y live in: window pixels under no
31
+ * scaling ancestor, design pixels under a designSize fit), so it applies
32
+ * 1:1 to the node or to content inside it. */
30
33
  dx: number
31
34
  dy: number
32
35
  /** Multiplicative span change since the previous frame (1 = unchanged, >1 = fingers spreading). */
33
36
  scale: number
34
37
  /** Rotation of the pointer pair since the previous frame, radians (0 with a single pointer). */
35
38
  rotation: number
36
- /** Current focal point in window coordinates (the clientX/clientY frame) - the zoom-about anchor. */
39
+ /** Current focal point in the handler node's LOCAL frame (its own
40
+ * pixels) - the zoom-about anchor for content inside it. */
37
41
  x: number
38
42
  y: number
39
43
  /** How many pointers are down right now. Consumers that give one- and
@@ -106,12 +110,25 @@ export interface TransformOptions {
106
110
  // path, so an active transform survives leaving the node or the window.
107
111
  // cancel() is the external-cancel hook; it ends an active gesture without
108
112
  // onTransformEnd. Options are read at event time.
113
+ //
114
+ // Frames: the slop, the span filter and its rate gates are finger-travel
115
+ // thresholds, so they are measured in window pixels (clientX/clientY);
116
+ // the focal movement (dx/dy) and the pair angle are measured in the node's
117
+ // parent frame (parentX/parentY), the frame the node's own coordinates
118
+ // live in, so a scaled ancestor (a designSize fit) is accounted for and
119
+ // the delta applies unchanged to the node or to content inside it; the
120
+ // focal point (x/y) is reported in the node's local frame (localX/localY),
121
+ // the anchor a zoom-about-the-fingers needs. Scale and rotation are ratios
122
+ // and angles, the same in every uniformly scaled frame. The event carries
123
+ // all three frames exactly, on and off the node.
109
124
  export function createTransform(options: TransformOptions) {
125
+ type Tracked = { cx: number; cy: number; px: number; py: number; lx: number; ly: number }
126
+ let at = (e: PointerEvent): Tracked => ({ cx: e.clientX, cy: e.clientY, px: e.parentX, py: e.parentY, lx: e.localX, ly: e.localY })
110
127
  // All tracked pointers, in down order (the first two define the rotation pair).
111
- let pointers = new Map<number, { x: number; y: number }>()
128
+ let pointers = new Map<number, Tracked>()
112
129
  let active = false
113
130
  // Slop baseline while armed; last delivered configuration while active.
114
- let ref: { x: number; y: number; span: number; angle: number } | null = null
131
+ let ref: { cx: number; cy: number; px: number; py: number; span: number; angle: number } | null = null
115
132
  // The zoom gate and filter (see the zoom-noise note above): scale stays 1
116
133
  // until the SMOOTHED span leaves spanBase by SLOP, then streams smoothed
117
134
  // ratios for the rest of the gesture.
@@ -128,27 +145,41 @@ export function createTransform(options: TransformOptions) {
128
145
  // emits nothing.
129
146
  let rebase = false
130
147
 
148
+ // Centroids in all three frames, the span in window pixels (it feeds the
149
+ // finger-travel gates), the pair angle in the parent frame.
131
150
  let measure = () => {
132
151
  let n = pointers.size
133
- let x = 0
134
- let y = 0
152
+ let cx = 0
153
+ let cy = 0
154
+ let px = 0
155
+ let py = 0
156
+ let lx = 0
157
+ let ly = 0
135
158
  for (let p of pointers.values()) {
136
- x += p.x
137
- y += p.y
159
+ cx += p.cx
160
+ cy += p.cy
161
+ px += p.px
162
+ py += p.py
163
+ lx += p.lx
164
+ ly += p.ly
138
165
  }
139
- x /= n
140
- y /= n
166
+ cx /= n
167
+ cy /= n
168
+ px /= n
169
+ py /= n
170
+ lx /= n
171
+ ly /= n
141
172
  let span = 0
142
- for (let p of pointers.values()) span += Math.hypot(p.x - x, p.y - y)
173
+ for (let p of pointers.values()) span += Math.hypot(p.cx - cx, p.cy - cy)
143
174
  span /= n
144
175
  let angle = 0
145
176
  if (n >= 2) {
146
177
  let pair = pointers.values()
147
178
  let a = pair.next().value!
148
179
  let b = pair.next().value!
149
- angle = Math.atan2(b.y - a.y, b.x - a.x)
180
+ angle = Math.atan2(b.py - a.py, b.px - a.px)
150
181
  }
151
- return { x, y, span, angle }
182
+ return { cx, cy, px, py, lx, ly, span, angle }
152
183
  }
153
184
 
154
185
  let reset = () => {
@@ -202,10 +233,10 @@ export function createTransform(options: TransformOptions) {
202
233
  if (rotation > Math.PI) rotation -= 2 * Math.PI
203
234
  else if (rotation < -Math.PI) rotation += 2 * Math.PI
204
235
  }
205
- let dx = m.x - ref.x
206
- let dy = m.y - ref.y
236
+ let dx = m.px - ref.px
237
+ let dy = m.py - ref.py
207
238
  ref = m
208
- options.onTransformMove?.({ dx, dy, scale, rotation, x: m.x, y: m.y, pointers: pointers.size })
239
+ options.onTransformMove?.({ dx, dy, scale, rotation, x: m.lx, y: m.ly, pointers: pointers.size })
209
240
  }
210
241
 
211
242
  // An unmount mid-gesture must not leave resolved claims behind (or a live
@@ -226,24 +257,22 @@ export function createTransform(options: TransformOptions) {
226
257
  // A finger joining an established gesture belongs to it outright; if
227
258
  // the arena refuses (resolved elsewhere) the finger stays out.
228
259
  if (!arena.steal(e.pointerId, owner)) return
229
- pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
260
+ pointers.set(e.pointerId, at(e))
230
261
  rebase = true
231
262
  return
232
263
  }
233
- pointers.set(e.pointerId, { x: e.clientX, y: e.clientY })
264
+ pointers.set(e.pointerId, at(e))
234
265
  ref = measure()
235
266
  },
236
267
  onPointerMove: (e: PointerEvent) => {
237
- let p = pointers.get(e.pointerId)
238
- if (!p || !ref) return
239
- p.x = e.clientX
240
- p.y = e.clientY
268
+ if (!pointers.has(e.pointerId) || !ref) return
269
+ pointers.set(e.pointerId, at(e))
241
270
  if (!active) {
242
271
  // Arming runs per event: the slop test tolerates a mixed-age
243
272
  // measure (8px against <=1 frame of staleness), and the arena
244
273
  // steal must happen synchronously inside the dispatch walk.
245
274
  let m = measure()
246
- let travel = Math.hypot(m.x - ref.x, m.y - ref.y)
275
+ let travel = Math.hypot(m.cx - ref.cx, m.cy - ref.cy)
247
276
  if (travel < SLOP && Math.abs(m.span - ref.span) < SLOP) return
248
277
  let taken: number[] = []
249
278
  let refused = false
package/src/types.d.ts CHANGED
@@ -175,6 +175,48 @@ export interface PaintProps {
175
175
  strokeWidth?: number
176
176
  }
177
177
 
178
+ /**
179
+ * A drop shadow behind a shape (CSS box-shadow field semantics, one shadow):
180
+ * the shape's outer geometry, offset by x/y, grown by `spread`, softened by
181
+ * `blur` (a CSS-style radius in logical px), painted in `color` under the
182
+ * shape. It casts from the shape's outer box whatever the draw style (strokes
183
+ * paint inside, so the box is the outer edge) - except on `path`, where the
184
+ * shadow mirrors the element's own fill/stroke silhouette and `spread` is
185
+ * rejected (an arbitrary path cannot be inflated exactly). `color` is
186
+ * required; there is no currentColor to inherit.
187
+ */
188
+ export interface ShadowProps {
189
+ x?: number
190
+ y?: number
191
+ blur?: number
192
+ spread?: number
193
+ color: Color
194
+ }
195
+
196
+ /**
197
+ * A subtree filter (the CSS filter functions, object form): the view's
198
+ * children are composited together, run through the set color operations -
199
+ * fused into one color matrix, applied in the fixed order grayscale, sepia,
200
+ * saturate, hueRotate, brightness, contrast, invert - and then blurred.
201
+ * `blur` is a CSS-style radius in logical px; `hueRotate` is radians (this
202
+ * API's angle convention); the rest are the CSS amounts (1 = unchanged for
203
+ * saturate/brightness/contrast, 0 = unchanged for the others; grayscale,
204
+ * sepia and invert saturate at 1). There is no `opacity` key - the view's
205
+ * own `opacity` prop is the same compositing layer. Like opacity, a filter
206
+ * on a non-boundary view costs a save_layer; on a repaintBoundary view it
207
+ * rides the composite for free. Hit testing is unaffected.
208
+ */
209
+ export interface FilterProps {
210
+ blur?: number
211
+ grayscale?: number
212
+ sepia?: number
213
+ saturate?: number
214
+ hueRotate?: number
215
+ brightness?: number
216
+ contrast?: number
217
+ invert?: number
218
+ }
219
+
178
220
  /** A percentage value, from `pct(50)`. Resolves against the element box. */
179
221
  export type Pct = { readonly __unit: "pct"; v: number }
180
222
 
@@ -607,11 +649,13 @@ export interface WindowShaderProps {
607
649
  program: ProgramId
608
650
  /**
609
651
  * Uniforms filled by name, paced to the next real repaint. A number drives
610
- * a scalar (`float`/`int`); a flat number array drives the declared GLSL
611
- * type: 2/3/4 for `vec2`/`vec3`/`vec4`, 16 (column-major) for `mat4`.
652
+ * a scalar (`float`/`int`); a flat number array (or a Float32Array /
653
+ * Float64Array) drives the declared GLSL type: 2/3/4 for
654
+ * `vec2`/`vec3`/`vec4`, 16 (column-major) for `mat4`. An undefined or null
655
+ * entry is skipped.
612
656
  */
613
- params?: Record<string, number | number[]>
614
- /** Extra sampler2D inputs: uniform name to texture id, or `{ id, filter?, wrap? }` for a per-binding sampling override. */
657
+ params?: Record<string, number | number[] | Float32Array | Float64Array>
658
+ /** Extra sampler2D inputs: uniform name to texture id, or `{ id, filter?, wrap? }` for a per-binding sampling override. An undefined or null entry is skipped. */
615
659
  textures?: TextureBindings
616
660
  /** Vertices drawn (attributeless triangles). Default 3, the covering triangle. */
617
661
  vertexCount?: number
@@ -671,7 +715,9 @@ export interface ViewOwnProps extends TransformProps, PointerProps {
671
715
  * skipping rasterization entirely. Costs texture memory and re-rasterizes
672
716
  * on layout-size or display-scale changes. Content painted outside the
673
717
  * element's layout box is cropped, and ancestor scale animations smear the
674
- * bitmap; best for screen-aligned, static, raster-expensive content.
718
+ * bitmap; best for screen-aligned, static, raster-expensive content. On a
719
+ * d-view the box is the inherited frame (the nearest laid-out ancestor's
720
+ * box, or the design size under a designSize view), same as a capture.
675
721
  *
676
722
  * "snapshot-no-aa" is "snapshot" rasterized without anti-aliasing: cheaper
677
723
  * (no multisampled scratch, one render pass), but vector content - svg
@@ -701,6 +747,19 @@ export interface ViewOwnProps extends TransformProps, PointerProps {
701
747
  * unrotated, opaque content.
702
748
  */
703
749
  shader?: ViewShaderProps | null
750
+ filter?: FilterProps
751
+ /**
752
+ * Filters the pixels already painted BENEATH the view's box, in place,
753
+ * before the view's own content draws - frosted glass over live content
754
+ * (CSS `backdrop-filter`). Same keys and semantics as `filter`. The
755
+ * region is the layout box; give the view an overflow clip with
756
+ * `clipRadius` for rounded glass. Costs an offscreen capture of the
757
+ * pixels beneath at that point in the frame, so treat it as a deliberate
758
+ * panel, not a casual style. Inside a `repaintBoundary="snapshot"`
759
+ * subtree it sees only that boundary's own offscreen content, never what
760
+ * is behind the boundary (the same containment `blendMode` has there).
761
+ */
762
+ backdropFilter?: FilterProps
704
763
  }
705
764
 
706
765
  /**
@@ -716,11 +775,13 @@ export interface ViewShaderProps {
716
775
  program: ProgramId
717
776
  /**
718
777
  * Uniforms filled by name, paced to the next real repaint. A number drives
719
- * a scalar (`float`/`int`); a flat number array drives the declared GLSL
720
- * type: 2/3/4 for `vec2`/`vec3`/`vec4`, 16 (column-major) for `mat4`.
778
+ * a scalar (`float`/`int`); a flat number array (or a Float32Array /
779
+ * Float64Array) drives the declared GLSL type: 2/3/4 for
780
+ * `vec2`/`vec3`/`vec4`, 16 (column-major) for `mat4`. An undefined or null
781
+ * entry is skipped.
721
782
  */
722
- params?: Record<string, number | number[]>
723
- /** Extra sampler2D inputs: uniform name to texture id, or `{ id, filter?, wrap? }` for a per-binding sampling override. */
783
+ params?: Record<string, number | number[] | Float32Array | Float64Array>
784
+ /** Extra sampler2D inputs: uniform name to texture id, or `{ id, filter?, wrap? }` for a per-binding sampling override. An undefined or null entry is skipped. */
724
785
  textures?: TextureBindings
725
786
  /**
726
787
  * Transparent margin in logical px on every side of the layout box, for
@@ -761,10 +822,13 @@ export interface RectProps extends PaintProps, PointerProps, DashProps {
761
822
  // number applies to all four corners; an array is [top-left, top-right,
762
823
  // bottom-right, bottom-left] (CSS border-radius order).
763
824
  radius?: number | [number, number, number, number]
825
+ shadow?: ShadowProps
764
826
  }
765
827
 
766
828
  // Strokes paint inside the box, same as `RectProps`.
767
- export interface OvalProps extends PaintProps, PointerProps, DashProps {}
829
+ export interface OvalProps extends PaintProps, PointerProps, DashProps {
830
+ shadow?: ShadowProps
831
+ }
768
832
 
769
833
  /**
770
834
  * A stroke's dash pattern, on every stroked primitive (`rect`, `oval`,
@@ -847,6 +911,8 @@ export interface LineProps extends PaintProps, PointerProps, DashProps {
847
911
  export interface PathProps extends PaintProps, PointerProps, DashProps {
848
912
  d?: string
849
913
  fillRule?: "nonzero" | "evenodd"
914
+ /** Shadows the drawn silhouette (fill and/or stroke); `spread` is rejected here. */
915
+ shadow?: ShadowProps
850
916
  }
851
917
 
852
918
  export type FontFamily = "sans" | "serif" | "mono" | (string & {})
@@ -957,7 +1023,8 @@ export interface TextureProps extends PaintProps, PointerProps {
957
1023
  // throws, and set src before params: a write with no src to route to
958
1024
  // throws). However often a signal writes, the target renders once per
959
1025
  // frame at the raster flush. A number drives a scalar (`float`/`int`); a
960
- // flat number array drives the declared GLSL type: 2/3/4 for
961
- // `vec2`/`vec3`/`vec4`, 16 (column-major) for `mat4`.
962
- params?: Record<string, number | number[]>
1026
+ // flat number array (or a Float32Array / Float64Array) drives the declared
1027
+ // GLSL type: 2/3/4 for `vec2`/`vec3`/`vec4`, 16 (column-major) for `mat4`.
1028
+ // An undefined or null entry is skipped.
1029
+ params?: Record<string, number | number[] | Float32Array | Float64Array>
963
1030
  }
package/src/window.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { createSignal, onCleanup, onSettled, flush } from "@solidjs/signals"
1
+ import { createSignal, getOwner, onCleanup, onSettled, runWithOwner, flush } from "@solidjs/signals"
2
2
  import { requestFrame, setPointerLock } from "flux:rendertree"
3
3
  import { renderFrame } from "srt:render"
4
4
  import { on, once } from "srt:events"
@@ -7,8 +7,8 @@ import { getEventHandler, focusedNode, setFocus, activateTextInput, setInterestR
7
7
  import { scanForOrphans, getNodePath } from "./renderer"
8
8
 
9
9
  /**
10
- * Leaves the current app, unconditionally: back to the launcher in a dev
11
- * client, quitting when standalone or at the launcher itself (on Android the
10
+ * Leaves the current app, unconditionally: back to the player in a dev
11
+ * client, quitting when standalone or at the player itself (on Android the
12
12
  * client backgrounds instead of dying). The default action of an unprevented
13
13
  * `back` event; call it directly to exit programmatically, e.g. after
14
14
  * intercepting back for an unsaved-changes dialog.
@@ -44,7 +44,10 @@ let refreshRate = 60
44
44
  * control, but measure their delays against the wall clock, not this paced
45
45
  * timeline. performance.now() is not on it either: it is real elapsed time
46
46
  * for measuring work; Date.now() is calendar time.
47
- * Returns a cleanup function; also auto-cleans within a reactive scope.
47
+ * Returns a cleanup function; also auto-cleans within an owned scope. Outside
48
+ * one - an effect's apply phase, an event handler - the returned cleanup is
49
+ * the only handle, so return it from the apply (`return onFrame(...)`) to run
50
+ * a loop only while a condition holds.
48
51
  */
49
52
  export function onFrame(fn: (tick: number, frame: number, rate: number) => void) {
50
53
  let frameId: number = null!
@@ -77,7 +80,7 @@ export function onFrame(fn: (tick: number, frame: number, rate: number) => void)
77
80
  cancelled = true
78
81
  animationFrames.delete(frameId)
79
82
  }
80
- onCleanup(cleanup)
83
+ if (getOwner()) onCleanup(cleanup)
81
84
  return cleanup
82
85
  }
83
86
 
@@ -101,7 +104,7 @@ interface ResizeEvent {
101
104
 
102
105
  export function onResize(fn: (data: ResizeEvent) => void) {
103
106
  let unsubscribe = on("resize", fn)
104
- onCleanup(unsubscribe)
107
+ if (getOwner()) onCleanup(unsubscribe)
105
108
  return unsubscribe
106
109
  }
107
110
 
@@ -110,9 +113,13 @@ export function onResize(fn: (data: ResizeEvent) => void) {
110
113
  // Singleton accessors over the same events as onResize / onWindowFocus. There
111
114
  // is one window, so these are bare accessors rather than a createX instance.
112
115
  // Lazily subscribed on first read (resize is sticky, so the first read sees the
113
- // current value); app-lifetime, so no onCleanup. `ownedWrite: true` on the
114
- // signals below is the sticky-replay-into-a-tracked-scope case explained on
115
- // the ensure* functions in environment.ts.
116
+ // current value); app-lifetime, so no onCleanup. Each is created under no
117
+ // owner (runWithOwner(null)): the first read is usually a component or a
118
+ // computation, and a sticky event replays its cached value synchronously
119
+ // inside on(), so creating the signal in the reader's scope would write it
120
+ // there too - a render-time write, which the guard rejects. Detached, the
121
+ // fact belongs to the app, not to whoever read it first (the reasoning
122
+ // in full is on the ensure* functions in environment.ts).
116
123
 
117
124
  let sizeAccessor: (() => { width: number; height: number }) | undefined
118
125
  let safeAreaAccessor: (() => SafeArea) | undefined
@@ -120,17 +127,19 @@ let displayScaleAccessor: (() => number) | undefined
120
127
 
121
128
  function ensureResizeState() {
122
129
  if (sizeAccessor) return
123
- let [size, setSize] = createSignal({ width: 0, height: 0 }, { ownedWrite: true })
124
- let [safe, setSafe] = createSignal<SafeArea>({ top: 0, left: 0, right: 0, bottom: 0 }, { ownedWrite: true })
125
- let [scale, setScale] = createSignal(1, { ownedWrite: true })
126
- on("resize", (e: ResizeEvent) => {
127
- setSize({ width: e.width, height: e.height })
128
- setSafe(e.safeArea)
129
- setScale(e.displayScale)
130
+ runWithOwner(null, () => {
131
+ let [size, setSize] = createSignal({ width: 0, height: 0 })
132
+ let [safe, setSafe] = createSignal<SafeArea>({ top: 0, left: 0, right: 0, bottom: 0 })
133
+ let [scale, setScale] = createSignal(1)
134
+ on("resize", (e: ResizeEvent) => {
135
+ setSize({ width: e.width, height: e.height })
136
+ setSafe(e.safeArea)
137
+ setScale(e.displayScale)
138
+ })
139
+ sizeAccessor = size
140
+ safeAreaAccessor = safe
141
+ displayScaleAccessor = scale
130
142
  })
131
- sizeAccessor = size
132
- safeAreaAccessor = safe
133
- displayScaleAccessor = scale
134
143
  }
135
144
 
136
145
  /** Current window size, as a reactive accessor. */
@@ -156,12 +165,14 @@ let focusedAccessor: (() => boolean) | undefined
156
165
  /** Whether the window currently has focus, as a reactive accessor. */
157
166
  export function windowFocused(): boolean {
158
167
  if (!focusedAccessor) {
159
- let [focused, setFocused] = createSignal(true)
160
- on("windowFocus", () => setFocused(true))
161
- on("windowBlur", () => setFocused(false))
162
- focusedAccessor = focused
168
+ runWithOwner(null, () => {
169
+ let [focused, setFocused] = createSignal(true)
170
+ on("windowFocus", () => setFocused(true))
171
+ on("windowBlur", () => setFocused(false))
172
+ focusedAccessor = focused
173
+ })
163
174
  }
164
- return focusedAccessor()
175
+ return focusedAccessor!()
165
176
  }
166
177
 
167
178
  /**
@@ -183,12 +194,14 @@ let pointerLockedAccessor: (() => boolean) | undefined
183
194
  /** Whether the pointer is currently locked (relative mouse mode), as a reactive accessor. */
184
195
  export function pointerLocked(): boolean {
185
196
  if (!pointerLockedAccessor) {
186
- let [locked, setLocked] = createSignal(false)
187
- // Sticky event: a subscriber after the lock still observes the state.
188
- on("pointerLock", ({ locked }: { locked: boolean }) => setLocked(locked))
189
- pointerLockedAccessor = locked
197
+ runWithOwner(null, () => {
198
+ let [locked, setLocked] = createSignal(false)
199
+ // Sticky event: a subscriber after the lock still observes the state.
200
+ on("pointerLock", ({ locked }: { locked: boolean }) => setLocked(locked))
201
+ pointerLockedAccessor = locked
202
+ })
190
203
  }
191
- return pointerLockedAccessor()
204
+ return pointerLockedAccessor!()
192
205
  }
193
206
 
194
207
  let keyboardHeightAccessor: (() => number) | undefined
@@ -201,11 +214,13 @@ let keyboardHeightAccessor: (() => number) | undefined
201
214
  */
202
215
  export function keyboardHeight(): number {
203
216
  if (!keyboardHeightAccessor) {
204
- let [height, setHeight] = createSignal(0)
205
- on("keyboardVisibility", ({ height: h }: { height: number }) => setHeight(h ?? 0))
206
- keyboardHeightAccessor = height
217
+ runWithOwner(null, () => {
218
+ let [height, setHeight] = createSignal(0)
219
+ on("keyboardVisibility", ({ height: h }: { height: number }) => setHeight(h ?? 0))
220
+ keyboardHeightAccessor = height
221
+ })
207
222
  }
208
- return keyboardHeightAccessor()
223
+ return keyboardHeightAccessor!()
209
224
  }
210
225
 
211
226
  // Post-layout handlers run in registration order from one bus subscription,
@@ -251,19 +266,19 @@ export function onLayout(fn: () => void) {
251
266
  let i = layoutHandlers.indexOf(fn)
252
267
  if (i >= 0) layoutHandlers.splice(i, 1)
253
268
  }
254
- onCleanup(unsubscribe)
269
+ if (getOwner()) onCleanup(unsubscribe)
255
270
  return unsubscribe
256
271
  }
257
272
 
258
273
  export function onWindowFocus(fn: () => void) {
259
274
  let unsubscribe = on("windowFocus", fn)
260
- onCleanup(unsubscribe)
275
+ if (getOwner()) onCleanup(unsubscribe)
261
276
  return unsubscribe
262
277
  }
263
278
 
264
279
  export function onWindowBlur(fn: () => void) {
265
280
  let unsubscribe = on("windowBlur", fn)
266
- onCleanup(unsubscribe)
281
+ if (getOwner()) onCleanup(unsubscribe)
267
282
  return unsubscribe
268
283
  }
269
284
 
@@ -295,7 +310,8 @@ let backHandlers: ((e: BackEvent) => void)[] = []
295
310
  * handler that does not prevent must not act either - the event is still on its
296
311
  * way to whoever will handle it.
297
312
  *
298
- * Returns a cleanup function; also auto-cleans within a reactive scope.
313
+ * Returns a cleanup function; also auto-cleans within an owned scope (outside
314
+ * one the returned cleanup is the only handle).
299
315
  */
300
316
  export function onBack(fn: (e: BackEvent) => void) {
301
317
  backHandlers.push(fn)
@@ -303,7 +319,7 @@ export function onBack(fn: (e: BackEvent) => void) {
303
319
  let i = backHandlers.lastIndexOf(fn)
304
320
  if (i >= 0) backHandlers.splice(i, 1)
305
321
  }
306
- onCleanup(cleanup)
322
+ if (getOwner()) onCleanup(cleanup)
307
323
  return cleanup
308
324
  }
309
325