@solidrt/core 0.0.61 → 0.0.62

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.
@@ -172,8 +172,13 @@ Rules, in order of leverage:
172
172
  every frame with prepareText + layoutNextLine (a shape that breathes, an
173
173
  obstacle that moves, an editor on every keystroke) is cheap - the lines'
174
174
  d-texts hit the same cache - while changing a text's font, size or
175
- weight re-shapes it. Animate text with transforms and paint (rule 5), or
176
- by re-breaking; not by resizing it per frame.
175
+ weight re-shapes it. Painting costs one paragraph draw per line run of
176
+ same-styled words (the joined line goes through the same cache, so a
177
+ line composition seen before is a lookup): a paragraph draws as its
178
+ lines, not its words, and a re-breaking one shapes only the lines whose
179
+ breaks moved. A justified line or a per-word style change draws per
180
+ word. Animate text with transforms and paint (rule 5), or by
181
+ re-breaking; not by resizing it per frame.
177
182
 
178
183
  ## Isolates: heavy work off the JS thread
179
184
 
@@ -235,6 +240,34 @@ not infer it from the desktop number.
235
240
  content-independent floor: if a trivial scene and a heavy one present at
236
241
  nearly the same rate, you are compositor-bound and tuning the scene is
237
242
  wasted effort.
243
+ - **Some paints cost a frame each on a tiled mobile GPU.** Measured on a
244
+ mid-range 2020 tablet (Adreno 610, Impeller GLES) with ten panes sliding
245
+ and resizing on a layout transition, each row by subtraction from the
246
+ compositor's own present record (`srt android --census`), against a
247
+ frame that ran at 60 fps with a 13 ms GPU span:
248
+ - a 3-stop linear-gradient fill per pane: +15 ms, 20 fps. A gradient is
249
+ shaded per pixel per frame; the same tint as a flat `d-rect` costs
250
+ nothing, and even a once-rendered 32x32 texture stretched over the pane
251
+ costs +5 ms over the flat fill.
252
+ - a rounded clip (`overflow="hidden"` with `clipRadius`) on a box whose
253
+ size is in flight: ~8 ms for one pane, ~13 ms for ten, most of it when
254
+ an image is under the clip. A static rounded clip is nearly free. Round
255
+ the paint instead (`radius` on `d-rect`, `d-texture` and `<texture>`)
256
+ and keep the clip rectangular.
257
+ - four 16 px draws per pane with `destination-out` and `destination-over`
258
+ blends: +16 ms. Any blend but source-over leaves the fast path,
259
+ whatever the size.
260
+ - seventy small source-over texture draws in place of ten pane-sized
261
+ ones: +10 ms. Draw count costs on its own.
262
+ - five 230-character paragraphs painted per word, 180 paragraph draws:
263
+ 20 fps; painted per line run, 30 draws (rule 10, what the runtime does
264
+ now): 60 fps.
265
+ - a laid-out `TextInput` inside a resizing box re-checks its breaks and
266
+ scroll against the box per frame, ~0.2 ms per field on that tablet;
267
+ a fixed box, or a paragraph while not editing, costs nothing per frame.
268
+ `get_stats`' `paintOps` (draws, paragraphs, clips, roundedClips,
269
+ saveLayers, blends, gradients) says which of these a frame carries, per
270
+ rebuild and for the window's worst frame.
238
271
  - **Per-frame writes are gated on the raster thread, not on vsync**, so a pass
239
272
  that costs more than a refresh period does not silently pile up. If
240
273
  `rasterQueue` climbs across queries while fps drops the raster thread is
package/docs/index.md CHANGED
@@ -164,6 +164,19 @@ launch the user started. Whether to load what `onSuspend` saved is the app's
164
164
  call. `env.visibility` is only whether anything is on screen, for pausing
165
165
  animation and audio, not the persistence moment.
166
166
 
167
+ A link from outside (a custom scheme link the OS routed to the app, or one
168
+ a dev tool sent) arrives raw: `env.launchLink` is the one this run of the
169
+ app was started with (null without; a hot reload starts at the location
170
+ the app last reported, see `reportLocation` in `srt:dev`), `onLink` fires
171
+ for one arriving while the app runs. What a link names is the app's to decide, and it is untrusted input:
172
+ validate before acting on any part of it. A router turns links into screens
173
+ for you; without one, parse it yourself. The scheme a packed app answers to
174
+ is its appId (`com.example.app://settings`); `registerProtocolHandler()`
175
+ makes the install the handler for it on Linux and Windows, where nothing
176
+ installs a single executable, and does nothing on Android, whose package
177
+ declares the scheme. A second desktop instance started with a link hands
178
+ it to the running one and exits.
179
+
167
180
  ```tsx
168
181
  onSuspend(() => file("session.json").write(JSON.stringify(state)))
169
182
  if (env.launch === "restored") state = JSON.parse(await file("session.json").text())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@solidrt/core",
3
- "version": "0.0.61",
3
+ "version": "0.0.62",
4
4
  "license": "MIT",
5
5
  "funding": "https://github.com/sponsors/wellawaretech",
6
6
  "author": "Antoine van Wel",
@@ -32,7 +32,7 @@
32
32
  "AGENTS.md"
33
33
  ],
34
34
  "devDependencies": {
35
- "@solidrt/flux-types": "0.0.61"
35
+ "@solidrt/flux-types": "0.0.62"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@solidjs/signals": "2.0.0-rc.9",
@@ -83,6 +83,21 @@ function ensureLaunchState() {
83
83
  launchValue ??= "fresh"
84
84
  }
85
85
 
86
+ // Fixed for the process like the launch fact (sticky `launchLink`, emitted
87
+ // before any app code runs), so a plain value rather than a signal.
88
+ let launchLinkValue: string | null | undefined
89
+
90
+ function ensureLaunchLinkState() {
91
+ if (launchLinkValue !== undefined) return
92
+ runWithOwner(null, () => {
93
+ on("launchLink", (e: { link?: string | null }) => {
94
+ launchLinkValue = typeof e.link === "string" ? e.link : null
95
+ })
96
+ })
97
+ // A runtime that reports no launch link launched without one.
98
+ launchLinkValue ??= null
99
+ }
100
+
86
101
  let visibilityAccessor: (() => Visibility) | undefined
87
102
 
88
103
  function ensureVisibilityState() {
@@ -257,6 +272,18 @@ export let env = {
257
272
  ensureLaunchState()
258
273
  return launchValue!
259
274
  },
275
+ /**
276
+ * The link this process was started with, or null: a custom scheme link
277
+ * the OS routed to the app (Android intent data; `srt render --link`),
278
+ * raw, exactly as it arrived. What it names is the app's to decide, and
279
+ * it is untrusted input: validate before acting on any part of it. A link
280
+ * arriving while the app runs is an event instead: `onLink`. Fixed for the
281
+ * process, so it needs no tracked scope.
282
+ */
283
+ get launchLink(): string | null {
284
+ ensureLaunchLinkState()
285
+ return launchLinkValue!
286
+ },
260
287
  /** Orientation of the display the window is on. */
261
288
  get orientation(): Orientation {
262
289
  ensureOrientationState()
package/src/index.ts CHANGED
@@ -3,7 +3,7 @@ export { setFocus, focusedNode, startTextInput, textInputActive, getFocusables,
3
3
  export type { BoundingBox, GlobalPointerEvent, TextLine } from "./core"
4
4
  export { parseColor, mixColors, withAlpha, brightness, createLinearGradient, createRadialGradient } from "./color"
5
5
  export type { Gradient, GradientStop } from "./color"
6
- export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, onSuspend, onQuit, exit, background } from "./window"
6
+ export { onFrame, onLayout, onResize, onWindowFocus, onWindowBlur, onBack, onLink, onSuspend, onQuit, exit, background, registerProtocolHandler } from "./window"
7
7
  export type { BackEvent, LifecycleHandler } from "./window"
8
8
  export { windowSize, safeArea, displayScale, windowFocused, keyboardHeight, lockPointer, pointerLocked } from "./window"
9
9
  export { env } from "./environment"
@@ -83,6 +83,18 @@ declare module "srt:app" {
83
83
  * `back` on Android; exposed there as `background`.
84
84
  */
85
85
  export function background(): void
86
+ /**
87
+ * Make this install the handler for the app's own scheme (its appId, as
88
+ * in `com.example.app://settings`), so links of it open the app: on Linux
89
+ * a desktop entry plus the mimeapps.list default, on Windows the
90
+ * HKCU\Software\Classes protocol key, both for the current user. Throws
91
+ * when the registration cannot be written. Android and macOS packages
92
+ * declare the scheme themselves, so the call has nothing to do there; a
93
+ * dev client registers nothing and logs a warning. Prefer the
94
+ * @solidrt/core `registerProtocolHandler`, the same call documented with
95
+ * the rest of the link surface.
96
+ */
97
+ export function registerProtocolHandler(): void
86
98
  }
87
99
 
88
100
  // Dev-server control surface (lattice). Present only in dev/go builds; in other
@@ -110,6 +122,17 @@ declare module "srt:dev" {
110
122
  * clients ever invoke commands.
111
123
  */
112
124
  export function registerDebug(name: string, fn: (args?: any) => unknown): void
125
+ /**
126
+ * Report where the app is, for tooling: a string naming the current
127
+ * location (a router's path; an app routing by hand may report its own),
128
+ * called again on every change. The runtime stores the value and never
129
+ * interprets it. The dev server reads it (`GET /__control__/link`, the
130
+ * get_location MCP tool), and a hot reload of the same app starts with the
131
+ * last reported location as its `env.launchLink`, so a rebuild comes back
132
+ * to the screen it left. null withdraws it. Callable in every build, but
133
+ * only dev clients ever read it.
134
+ */
135
+ export function reportLocation(location: string | null): void
113
136
  }
114
137
 
115
138
  // Installed-app management (lattice), the player's surface over the client's
package/src/text-input.ts CHANGED
@@ -256,9 +256,11 @@ export type TextEditorLayout = {
256
256
  * frame. A size change that re-breaks nothing (an empty field, lines that
257
257
  * still fit, a height-only change) stops at the line placement: the lines,
258
258
  * the caret and everything the caller derives from them keep their values
259
- * and re-run nothing, so a field inside a resizing box costs one placement
260
- * per frame, not a flush of its whole graph. Pure geometry: no caret
261
- * rendering and no placeholder/visual policy.
259
+ * and re-run nothing, and the placement reads the break inputs through a
260
+ * memo of its own rather than `input()` (which a caller builds per read),
261
+ * so a field inside a resizing box costs one placement of its units per
262
+ * frame, not a flush of its whole graph. Pure geometry: no caret rendering
263
+ * and no placeholder/visual policy.
262
264
  */
263
265
  export function createTextEditorLayout(
264
266
  viewport: () => { id: number } | undefined,
@@ -283,6 +285,19 @@ export function createTextEditorLayout(
283
285
  return prepareText(text, { ...font(), runs, carets: true })
284
286
  })
285
287
 
288
+ // What the breaks depend on, apart from the rest of the input: the caller
289
+ // builds input() per read (a field's value, font, caret and runs - some
290
+ // twenty reactive reads), and a placement re-run for a viewport width
291
+ // change must not pay for that. Equal by field, so only a real change
292
+ // reaches the placement.
293
+ let breaking = createMemo(
294
+ (): BreakInput => {
295
+ let { text, wrap, caretWidth = 0 } = input()
296
+ return { text, wrap, caretWidth }
297
+ },
298
+ { equals: sameBreakInput },
299
+ )
300
+
286
301
  // Lines carry their unit range so the caret math walks only their units.
287
302
  // Equal placements (same units, same breaks and metrics) keep the previous
288
303
  // value, so nothing downstream re-runs for a width change that changed
@@ -290,11 +305,13 @@ export function createTextEditorLayout(
290
305
  // keeps the previous object: a row bound to it (a non-keyed <For> item)
291
306
  // sees no change and writes nothing, so a moved break costs the lines it
292
307
  // touched, not every line of the field.
293
- type PlacedLine = EditorLine & { from: number; to: number }
294
- type Placement = { units: TextUnit[]; lines: PlacedLine[] }
308
+ type PlacedLine = EditorLine & { from: number; to: number; hardBreak: boolean }
309
+ // `holds` is the range of wrap widths [min, max) the breaker yields these
310
+ // same lines at (see holdRange); the layout handler skips a width inside it.
311
+ type Placement = { units: TextUnit[]; lines: PlacedLine[]; holds: [number, number] }
295
312
  let placed = createMemo(
296
313
  (prev): Placement => {
297
- let { text, wrap, caretWidth = 0 } = input()
314
+ let { text, wrap, caretWidth } = breaking()
298
315
  // Wrapped lines leave room for the caret at the end of a full line, so a
299
316
  // wrapping editor never scrolls horizontally.
300
317
  let width = wrap ? Math.max(0, viewportWidth() - caretWidth) : Infinity
@@ -305,21 +322,33 @@ export function createTextEditorLayout(
305
322
  let line = layoutNextLine(units, cursor, width)
306
323
  let hardBreak = false
307
324
  while (line) {
308
- out.push({ start: line.start, end: line.end, y, height: line.height, width: line.width, from: line.from, to: line.to })
325
+ out.push({
326
+ start: line.start,
327
+ end: line.end,
328
+ y,
329
+ height: line.height,
330
+ width: line.width,
331
+ from: line.from,
332
+ to: line.to,
333
+ hardBreak: line.hardBreak,
334
+ })
309
335
  y += line.height
310
336
  hardBreak = line.hardBreak
311
337
  line = layoutNextLine(units, line.cursor, width)
312
338
  }
313
339
  if (out.length === 0 || hardBreak) {
314
340
  let n = units.units.length
315
- out.push({ start: text.length, end: text.length, y, height: space().height, width: 0, from: n, to: n })
341
+ out.push({ start: text.length, end: text.length, y, height: space().height, width: 0, from: n, to: n, hardBreak: false })
316
342
  }
317
343
  if (prev && prev.units === units.units) {
318
344
  for (let i = 0; i < out.length && i < prev.lines.length; i++) {
319
345
  if (sameLine(prev.lines[i]!, out[i]!)) out[i] = prev.lines[i]!
320
346
  }
321
347
  }
322
- return { units: units.units, lines: out }
348
+ // Split units are the width's own (a unit wider than it), so those
349
+ // lines hold for no other width.
350
+ let holds: [number, number] = units.units === prepared().units ? holdRange(units.units, out) : [Infinity, Infinity]
351
+ return { units: units.units, lines: out, holds }
323
352
  },
324
353
  { equals: samePlacement },
325
354
  )
@@ -452,7 +481,7 @@ export function createTextEditorLayout(
452
481
  // when the caret has left the visible range, so the memo carries the
453
482
  // retained position across recomputes.
454
483
  let scrollX = createMemo((prev: number | undefined): number => {
455
- let { caretWidth = 0, wrap } = input()
484
+ let { caretWidth, wrap } = breaking()
456
485
  if (wrap) return 0
457
486
  let contentWidth = lines().reduce((w, l) => Math.max(w, l.width), 0)
458
487
  let c = caret()
@@ -465,12 +494,43 @@ export function createTextEditorLayout(
465
494
  return follow(prev ?? 0, c.y, c.height, viewportHeight(), last.y + last.height)
466
495
  })
467
496
 
497
+ // Whether the lines placed at the last written width are what the breaker
498
+ // yields at `width` too. Such a width is not written: the write would mark
499
+ // the whole graph below the placement for a re-check that finds nothing,
500
+ // which is what a field inside a resizing box paid per frame. So
501
+ // viewportWidth lags the box by less than a break while wrapping, and a
502
+ // placement re-run for a text change at the lagging width is checked here
503
+ // again on that frame and corrected before paint (writes from onLayout
504
+ // land in the same frame's re-layout; the same holds for the height
505
+ // below). Unwrapped, the width is the horizontal scroll's extent and is
506
+ // always written.
507
+ let keepsBreaks = (width: number): boolean => {
508
+ let { wrap, caretWidth } = breaking()
509
+ if (!wrap) return false
510
+ let [min, max] = placed().holds
511
+ let w = Math.max(0, width - caretWidth)
512
+ return w >= min && w < max
513
+ }
514
+
515
+ // The same for the height: a height at which the retained scroll offset
516
+ // still keeps the caret in view is not written, so the scroll graph stays
517
+ // untouched while a short field's box grows and shrinks.
518
+ let keepsScroll = (height: number): boolean => {
519
+ let ls = lines()
520
+ let last = ls[ls.length - 1]!
521
+ let c = caret()
522
+ let current = scrollY()
523
+ return follow(current, c.y, c.height, height, last.y + last.height) === current
524
+ }
525
+
468
526
  onLayout(() => {
469
527
  let node = viewport()
470
528
  if (!node) return
471
529
  let box = getLayoutBox(node)
472
- setViewportWidth(box?.width ?? 0)
473
- setViewportHeight(box?.height ?? 0)
530
+ let width = box?.width ?? 0
531
+ let height = box?.height ?? 0
532
+ if (!keepsBreaks(width)) setViewportWidth(width)
533
+ if (!keepsScroll(height)) setViewportHeight(height)
474
534
  })
475
535
 
476
536
  return { lines, caret, caretLine, offsetAtX, selectionRects, lineAtY, step, scrollX, scrollY }
@@ -519,6 +579,33 @@ function splitWide(prepared: PreparedText, width: number): PreparedText {
519
579
  return { text: prepared.text, units }
520
580
  }
521
581
 
582
+ // The wrap widths [min, max) at which the greedy breaker yields exactly
583
+ // `lines` over `units`: every line's ink fits (min), and no line's first
584
+ // unit fits at the end of the line before it (max), except across a hard
585
+ // break, where it never joins. Greedy breaking is decided line by line on
586
+ // just those two tests, so inside the range the placement is unchanged.
587
+ function holdRange(units: TextUnit[], lines: { from: number; to: number; width: number; hardBreak: boolean }[]): [number, number] {
588
+ let min = 0
589
+ let max = Infinity
590
+ for (let i = 0; i < lines.length; i++) {
591
+ let line = lines[i]!
592
+ if (line.width > min) min = line.width
593
+ let next = lines[i + 1]
594
+ if (!next || line.hardBreak || next.from >= units.length) continue
595
+ let pen = 0
596
+ for (let u = line.from; u < line.to; u++) pen += units[u]!.advance
597
+ let join = pen + unitInk(units, next.from)
598
+ if (join < max) max = join
599
+ }
600
+ return [min, max]
601
+ }
602
+
603
+ type BreakInput = { text: string; wrap: boolean; caretWidth: number }
604
+
605
+ function sameBreakInput(a: BreakInput, b: BreakInput): boolean {
606
+ return a.text === b.text && a.wrap === b.wrap && a.caretWidth === b.caretWidth
607
+ }
608
+
522
609
  // Font options compared field by field: the same keys with the same values.
523
610
  function sameOptions(a: MeasureTextOptions, b: MeasureTextOptions): boolean {
524
611
  let ka = Object.keys(a) as (keyof MeasureTextOptions)[]
package/src/window.ts CHANGED
@@ -2,7 +2,7 @@ import { createSignal, getOwner, onCleanup, onSettled, runWithOwner, flush } fro
2
2
  import { requestFrame, setPointerLock } from "flux:rendertree"
3
3
  import { renderFrame } from "srt:render"
4
4
  import { on, once } from "srt:events"
5
- import { exit as nativeExit, background as nativeBackground } from "srt:app"
5
+ import { exit as nativeExit, background as nativeBackground, registerProtocolHandler as nativeRegisterProtocolHandler } from "srt:app"
6
6
  import { platform } from "flux:process"
7
7
  import { getEventHandler, focusedNode, setFocus, activateTextInput, setInterestRoot } from "./core"
8
8
  import { scanForOrphans, getNodePath } from "./renderer"
@@ -141,6 +141,20 @@ export function background() {
141
141
  nativeBackground()
142
142
  }
143
143
 
144
+ /**
145
+ * Makes this install the handler for the app's own scheme, so links like
146
+ * `com.example.app://settings` (the scheme is the appId) open the app from a
147
+ * browser, another app or the shell: `env.launchLink` when it starts the
148
+ * app, `onLink` when the app already runs. The web's name, simplified: no
149
+ * arguments, the scheme is fixed. A packed desktop app has to call it,
150
+ * since nothing installs a single executable (call it again after moving
151
+ * the executable); Android and macOS packages declare the scheme, so the
152
+ * call does nothing there. Throws when the registration cannot be written.
153
+ */
154
+ export function registerProtocolHandler() {
155
+ nativeRegisterProtocolHandler()
156
+ }
157
+
144
158
  // The default action of an unprevented back event: what the platform itself
145
159
  // does at the root of an app. Android backgrounds (since Android 12);
146
160
  // everywhere else back at the root ends the app.
@@ -417,6 +431,23 @@ export function onWindowFocus(fn: () => void) {
417
431
  return unsubscribe
418
432
  }
419
433
 
434
+ /**
435
+ * Calls `fn` with a link handed to the running app from outside: a custom
436
+ * scheme link the OS routed here (another app, a QR code, a notification),
437
+ * or one a dev tool sent (`open_link` over MCP, `POST /__control__/link`).
438
+ * The raw string, exactly as it arrived; the app decides what it means, and
439
+ * it is untrusted input: validate before acting on any part of it. The link
440
+ * the app was *started* with is not an event (nothing was listening yet) but
441
+ * a fact: `env.launchLink`.
442
+ *
443
+ * Returns a cleanup function; also auto-cleans within an owned scope.
444
+ */
445
+ export function onLink(fn: (link: string) => void) {
446
+ let unsubscribe = on("link", (e: { link: string }) => fn(e.link))
447
+ if (getOwner()) onCleanup(unsubscribe)
448
+ return unsubscribe
449
+ }
450
+
420
451
  export function onWindowBlur(fn: () => void) {
421
452
  let unsubscribe = on("windowBlur", fn)
422
453
  if (getOwner()) onCleanup(unsubscribe)