@solidrt/core 0.0.10 → 0.0.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +112 -0
- package/README.md +2 -0
- package/jsx-runtime.d.ts +3 -0
- package/package.json +8 -5
- package/src/camera.ts +83 -40
- package/src/color.ts +58 -0
- package/src/core.ts +24 -18
- package/src/gpu.ts +51 -14
- package/src/image.ts +19 -0
- package/src/index.ts +7 -3
- package/src/microphone.ts +43 -25
- package/src/renderer.ts +25 -12
- package/src/speech-recognition.ts +149 -0
- package/src/text-input.ts +207 -0
- package/src/types.d.ts +27 -49
- package/src/window.ts +88 -8
- package/src/camera-view.tsx +0 -55
- package/src/speech.ts +0 -67
package/src/microphone.ts
CHANGED
|
@@ -1,40 +1,58 @@
|
|
|
1
|
-
// Microphone capture. A session delivers raw mono
|
|
2
|
-
// requested sample rate (the device format is converted
|
|
3
|
-
// drains whatever was captured since the last call. Captured
|
|
4
|
-
// until read, so poll read() regularly (e.g. once per frame) while
|
|
1
|
+
// Microphone capture, reactive (SolidJS) layer. A session delivers raw mono
|
|
2
|
+
// float32 samples at the requested sample rate (the device format is converted
|
|
3
|
+
// by SDL); read() drains whatever was captured since the last call. Captured
|
|
4
|
+
// audio buffers until read, so poll read() regularly (e.g. once per frame) while
|
|
5
|
+
// open.
|
|
6
|
+
//
|
|
7
|
+
// The imperative primitive lives in the `flux:microphone` module; import
|
|
8
|
+
// { open, listMicrophones } from "flux:microphone" for non-reactive use.
|
|
5
9
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
name: string
|
|
9
|
-
}
|
|
10
|
+
import { createSignal, onCleanup } from "@solidjs/signals"
|
|
11
|
+
import { open } from "flux:microphone"
|
|
10
12
|
|
|
11
13
|
export type MicrophoneOptions = {
|
|
12
|
-
/** Explicit device id from listMicrophones(); default is the system default
|
|
14
|
+
/** Explicit device id from flux:microphone listMicrophones(); default is the system default. */
|
|
13
15
|
microphone?: number
|
|
14
16
|
/** Sample rate of the delivered samples (the device rate is converted). Default 16000. */
|
|
15
17
|
sampleRate?: number
|
|
16
18
|
}
|
|
17
19
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
20
|
+
/** A live microphone with reactive lifecycle. */
|
|
21
|
+
export type MicrophoneStream = {
|
|
22
|
+
/** Sample rate of read() samples (0 if opening failed). */
|
|
23
|
+
sampleRate(): number
|
|
24
|
+
/** Drain the mono float32 samples captured since the last read (empty if not open). */
|
|
22
25
|
read(): Float32Array
|
|
23
|
-
/**
|
|
24
|
-
|
|
26
|
+
/** Set if opening failed. */
|
|
27
|
+
error(): Error | undefined
|
|
25
28
|
}
|
|
26
29
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
+
/**
|
|
31
|
+
* Opens a microphone and owns its lifecycle: closes when the reactive owner is
|
|
32
|
+
* disposed. Capture stays pull-based, so read() drains samples on demand (e.g.
|
|
33
|
+
* once per frame). For imperative use, call open() from "flux:microphone".
|
|
34
|
+
*/
|
|
35
|
+
export function createMicrophone(options: MicrophoneOptions = {}): MicrophoneStream {
|
|
36
|
+
let [error, setError] = createSignal<Error | undefined>(undefined)
|
|
37
|
+
let session: ReturnType<typeof open> | undefined
|
|
38
|
+
let rate = 0
|
|
39
|
+
try {
|
|
40
|
+
session = open(options)
|
|
41
|
+
rate = session.sampleRate
|
|
42
|
+
} catch (e) {
|
|
43
|
+
setError(e instanceof Error ? e : new Error(String(e)))
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
onCleanup(() => {
|
|
47
|
+
if (session) {
|
|
48
|
+
session.close()
|
|
49
|
+
session = undefined
|
|
50
|
+
}
|
|
51
|
+
})
|
|
30
52
|
|
|
31
|
-
// Async to leave room for an OS permission prompt on platforms that need one
|
|
32
|
-
// (the desktop backends open synchronously).
|
|
33
|
-
export async function openMicrophone(options: MicrophoneOptions = {}): Promise<Microphone> {
|
|
34
|
-
let opened = microphone.open(options)
|
|
35
53
|
return {
|
|
36
|
-
sampleRate:
|
|
37
|
-
read: () =>
|
|
38
|
-
|
|
54
|
+
sampleRate: () => rate,
|
|
55
|
+
read: () => (session ? session.read() : new Float32Array(0)),
|
|
56
|
+
error,
|
|
39
57
|
}
|
|
40
58
|
}
|
package/src/renderer.ts
CHANGED
|
@@ -1,7 +1,9 @@
|
|
|
1
|
-
import { createRoot
|
|
1
|
+
import { createRoot } from "@solidjs/signals"
|
|
2
2
|
import { createRenderer } from "@solidjs/universal"
|
|
3
|
+
import * as tree from "flux:rendertree"
|
|
3
4
|
import { attachWindow } from "./window"
|
|
4
|
-
import {
|
|
5
|
+
import { setEventHandler, cleanupNodeHandlers, getFocusedNodeId, setFocus } from "./core"
|
|
6
|
+
import { parseColor, isGradient } from "./color"
|
|
5
7
|
|
|
6
8
|
export { getEventHandler } from "./core"
|
|
7
9
|
|
|
@@ -47,8 +49,8 @@ export let {
|
|
|
47
49
|
|
|
48
50
|
// console.debug("[srt] createElement", proxy.id, elementType)
|
|
49
51
|
|
|
50
|
-
if (elementType === "window")
|
|
51
|
-
else
|
|
52
|
+
if (elementType === "window") tree.createRoot(proxy.id)
|
|
53
|
+
else tree.createNode(proxy.id, elementType)
|
|
52
54
|
|
|
53
55
|
return proxy
|
|
54
56
|
},
|
|
@@ -56,14 +58,14 @@ export let {
|
|
|
56
58
|
createTextNode: (value: string): ProxyNode => {
|
|
57
59
|
let proxy = createProxyNode("d-span")
|
|
58
60
|
// console.debug("[srt] createTextNode", proxy.id, value)
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
tree.createNode(proxy.id, "d-span")
|
|
62
|
+
tree.setProperty(proxy.id, "text", "" + value)
|
|
61
63
|
return proxy
|
|
62
64
|
},
|
|
63
65
|
|
|
64
66
|
replaceText: (node: ProxyNode, value: string): void => {
|
|
65
67
|
// console.debug("[srt] replaceText", node.id, value)
|
|
66
|
-
|
|
68
|
+
tree.setProperty(node.id, "text", "" + value)
|
|
67
69
|
},
|
|
68
70
|
|
|
69
71
|
isTextNode: (node: ProxyNode): boolean => node?.elementType === "d-span",
|
|
@@ -77,12 +79,17 @@ export let {
|
|
|
77
79
|
return
|
|
78
80
|
}
|
|
79
81
|
|
|
82
|
+
if (name === "color" && isGradient(value)) {
|
|
83
|
+
tree.setProperty(node.id, name, value)
|
|
84
|
+
return
|
|
85
|
+
}
|
|
86
|
+
|
|
80
87
|
if (name === "color" && typeof value === "string") {
|
|
81
|
-
|
|
88
|
+
tree.setProperty(node.id, name, parseColor(value))
|
|
82
89
|
return
|
|
83
90
|
}
|
|
84
91
|
|
|
85
|
-
|
|
92
|
+
tree.setProperty(node.id, name, value)
|
|
86
93
|
},
|
|
87
94
|
|
|
88
95
|
insertNode: (parent: ProxyNode, node: ProxyNode, anchor?: ProxyNode): void => {
|
|
@@ -104,8 +111,8 @@ export let {
|
|
|
104
111
|
|
|
105
112
|
// console.debug("[srt] insertNode", parent.id, node.id, anchor?.id ?? "")
|
|
106
113
|
|
|
107
|
-
if (anchor)
|
|
108
|
-
else
|
|
114
|
+
if (anchor) tree.insertNode(parent.id, node.id, anchor.id)
|
|
115
|
+
else tree.insertNode(parent.id, node.id)
|
|
109
116
|
}
|
|
110
117
|
},
|
|
111
118
|
|
|
@@ -121,7 +128,7 @@ export let {
|
|
|
121
128
|
}
|
|
122
129
|
node.parent = undefined
|
|
123
130
|
|
|
124
|
-
|
|
131
|
+
tree.deleteNode(parent.id, node.id)
|
|
125
132
|
|
|
126
133
|
// Recursively clean up node and all descendants. Clear focus before
|
|
127
134
|
// dropping handlers so onBlur still fires for a focused descendant.
|
|
@@ -145,6 +152,12 @@ export let {
|
|
|
145
152
|
},
|
|
146
153
|
})
|
|
147
154
|
|
|
155
|
+
/**
|
|
156
|
+
* Mounts a SolidRT app. Call once at the top level: `render(() => <App />)`.
|
|
157
|
+
* The element returned by `code` MUST be a `<window>` (it becomes the native
|
|
158
|
+
* window and root of the render tree); anything else throws. Runs inside a
|
|
159
|
+
* reactive root, so the whole tree is disposed together on engine reload.
|
|
160
|
+
*/
|
|
148
161
|
export function render(code: () => any) {
|
|
149
162
|
createRoot(() => {
|
|
150
163
|
let root = code()
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
// Speech recognition. A session captures the microphone, segments utterances
|
|
2
|
+
// by silence (Silero VAD) and transcribes each one with Whisper, delivering
|
|
3
|
+
// final transcripts through onResult. With wakeWord the session starts
|
|
4
|
+
// asleep behind an efficient wake word detector (livekit-wakeword) and only
|
|
5
|
+
// transcribes after the wake word. startRecognition resolves once the models
|
|
6
|
+
// are loaded and listening has begun; it rejects when loading fails.
|
|
7
|
+
// Models are passed as bytes so any source composes: flux:fs file(), fetch
|
|
8
|
+
// (incl. the dev-server file proxy), or a download cache layered on top.
|
|
9
|
+
// Requires a runtime built with speech support.
|
|
10
|
+
|
|
11
|
+
import { createSignal, onCleanup } from "@solidjs/signals"
|
|
12
|
+
|
|
13
|
+
export type SpeechOptions = {
|
|
14
|
+
/** A ggml Whisper model (file contents, e.g. ggml-tiny.en.bin). */
|
|
15
|
+
model: Uint8Array
|
|
16
|
+
/** A ggml Silero VAD model (file contents). */
|
|
17
|
+
vadModel: Uint8Array
|
|
18
|
+
/** Whisper language code; "auto" detects (multilingual models only). Default "en". */
|
|
19
|
+
lang?: string
|
|
20
|
+
/** Explicit microphone device id from listMicrophones(). */
|
|
21
|
+
microphone?: number
|
|
22
|
+
/**
|
|
23
|
+
* Keep transcribing utterance after utterance. Default true. Set false to
|
|
24
|
+
* stop after the first final result (with wakeWord: re-arm instead, one
|
|
25
|
+
* result per wake). Inverse of the Web Speech API default (false there).
|
|
26
|
+
*/
|
|
27
|
+
continuous?: boolean
|
|
28
|
+
/** Also deliver snapshot transcripts (final: false) while an utterance is still being spoken. */
|
|
29
|
+
interimResults?: boolean
|
|
30
|
+
/**
|
|
31
|
+
* Wake word: start asleep, fire onWake when it is heard, then transcribe
|
|
32
|
+
* the speech that follows. How the wake word is specified depends on the
|
|
33
|
+
* engine. The current engine detects with a trained classifier and takes
|
|
34
|
+
* the model's bytes (livekit-wakeword ONNX, e.g. the pretrained "hey
|
|
35
|
+
* livekit"; custom phrases are trained offline with its toolkit). Phrase
|
|
36
|
+
* strings are reserved for engines that match text; passing them to this
|
|
37
|
+
* engine rejects with an error.
|
|
38
|
+
*/
|
|
39
|
+
wakeWord?: Uint8Array | string | string[]
|
|
40
|
+
/** Detector confidence (0..1) that counts as a wake. Default 0.5. */
|
|
41
|
+
wakeThreshold?: number
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type SpeechResult = {
|
|
45
|
+
/** Transcript of the utterance (a snapshot of it when isFinal is false). */
|
|
46
|
+
transcript: string
|
|
47
|
+
/** True for the completed utterance, false for interim snapshots. */
|
|
48
|
+
isFinal: boolean
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type SpeechSession = {
|
|
52
|
+
/** Receive transcripts (replaces any previous callback). */
|
|
53
|
+
onResult(callback: (result: SpeechResult) => void): void
|
|
54
|
+
/** The user started speaking (replaces any previous callback). */
|
|
55
|
+
onSpeechStart(callback: () => void): void
|
|
56
|
+
/** The utterance ended; its final result follows once transcribed. */
|
|
57
|
+
onSpeechEnd(callback: () => void): void
|
|
58
|
+
/** The wake word was heard (wakeWordModel sessions only). */
|
|
59
|
+
onWake(callback: () => void): void
|
|
60
|
+
/** Release the microphone and discard any utterance in progress. */
|
|
61
|
+
stop(): void
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export async function startRecognition(options: SpeechOptions): Promise<SpeechSession> {
|
|
65
|
+
let started = await speech.start(options)
|
|
66
|
+
return {
|
|
67
|
+
onResult: (callback: (result: SpeechResult) => void) => speech.setResultCallback(started.handle, callback),
|
|
68
|
+
onSpeechStart: (callback: () => void) => speech.setSpeechStartCallback(started.handle, callback),
|
|
69
|
+
onSpeechEnd: (callback: () => void) => speech.setSpeechEndCallback(started.handle, callback),
|
|
70
|
+
onWake: (callback: () => void) => speech.setWakeCallback(started.handle, callback),
|
|
71
|
+
stop: () => speech.stop(started.handle),
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** A live recognition session as reactive accessors. */
|
|
76
|
+
export type SpeechStream = {
|
|
77
|
+
/** True once the models are loaded and listening has begun. */
|
|
78
|
+
ready(): boolean
|
|
79
|
+
/** Latest transcript, "" until the first result (a snapshot while isFinal is false). */
|
|
80
|
+
transcript(): string
|
|
81
|
+
/** Whether transcript() is the completed utterance rather than an interim snapshot. */
|
|
82
|
+
isFinal(): boolean
|
|
83
|
+
/** True while the user is mid-utterance (between speech start and end). */
|
|
84
|
+
speaking(): boolean
|
|
85
|
+
/** True from when an utterance ends until its transcript arrives. */
|
|
86
|
+
transcribing(): boolean
|
|
87
|
+
/**
|
|
88
|
+
* wakeWord sessions only: true from when the wake word is heard until the
|
|
89
|
+
* following command is transcribed (the next final result), then false.
|
|
90
|
+
* Always false without a wakeWord.
|
|
91
|
+
*/
|
|
92
|
+
awake(): boolean
|
|
93
|
+
/** Set if loading or starting failed. */
|
|
94
|
+
error(): Error | undefined
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Starts speech recognition and exposes it as reactive signals: read
|
|
99
|
+
* transcript()/isFinal() for results, speaking() and awake() for session
|
|
100
|
+
* state. Stops when the reactive owner is disposed. The lower-level
|
|
101
|
+
* startRecognition() is the imperative alternative.
|
|
102
|
+
*/
|
|
103
|
+
export function createSpeechRecognition(options: SpeechOptions): SpeechStream {
|
|
104
|
+
let [ready, setReady] = createSignal(false)
|
|
105
|
+
let [transcript, setTranscript] = createSignal("")
|
|
106
|
+
let [isFinal, setIsFinal] = createSignal(false)
|
|
107
|
+
let [speaking, setSpeaking] = createSignal(false)
|
|
108
|
+
let [transcribing, setTranscribing] = createSignal(false)
|
|
109
|
+
let [awake, setAwake] = createSignal(false)
|
|
110
|
+
let [error, setError] = createSignal<Error | undefined>(undefined)
|
|
111
|
+
let handle: number | undefined
|
|
112
|
+
let disposed = false
|
|
113
|
+
|
|
114
|
+
speech
|
|
115
|
+
.start(options)
|
|
116
|
+
.then((started) => {
|
|
117
|
+
if (disposed) {
|
|
118
|
+
speech.stop(started.handle)
|
|
119
|
+
return
|
|
120
|
+
}
|
|
121
|
+
handle = started.handle
|
|
122
|
+
speech.setResultCallback(started.handle, (result) => {
|
|
123
|
+
setTranscript(result.transcript)
|
|
124
|
+
setIsFinal(result.isFinal)
|
|
125
|
+
if (result.isFinal) {
|
|
126
|
+
setTranscribing(false)
|
|
127
|
+
setAwake(false)
|
|
128
|
+
}
|
|
129
|
+
})
|
|
130
|
+
speech.setSpeechStartCallback(started.handle, () => setSpeaking(true))
|
|
131
|
+
speech.setSpeechEndCallback(started.handle, () => {
|
|
132
|
+
setSpeaking(false)
|
|
133
|
+
setTranscribing(true)
|
|
134
|
+
})
|
|
135
|
+
speech.setWakeCallback(started.handle, () => setAwake(true))
|
|
136
|
+
setReady(true)
|
|
137
|
+
})
|
|
138
|
+
.catch((e) => setError(e instanceof Error ? e : new Error(String(e))))
|
|
139
|
+
|
|
140
|
+
onCleanup(() => {
|
|
141
|
+
disposed = true
|
|
142
|
+
if (handle !== undefined) {
|
|
143
|
+
speech.stop(handle)
|
|
144
|
+
handle = undefined
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
return { ready, transcript, isFinal, speaking, transcribing, awake, error }
|
|
149
|
+
}
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Headless text-input mechanism. These primitives own the objective parts of
|
|
2
|
+
// an editable single-line field -- the value buffer (text + caret/selection)
|
|
3
|
+
// and the scroll-to-caret geometry -- and nothing with a UI opinion. Caret
|
|
4
|
+
// blink, keybindings, placeholder and styling are policy and belong to the
|
|
5
|
+
// component (the "skin") that composes these.
|
|
6
|
+
|
|
7
|
+
import { createSignal, flush } from "@solidjs/signals"
|
|
8
|
+
import { getBoundingBox, measureText } from "./core"
|
|
9
|
+
import { onLayout } from "./window"
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* A text selection as anchor/focus character offsets, following the same model
|
|
13
|
+
* as the platform editors (Flutter's TextSelection, the DOM Selection): the
|
|
14
|
+
* anchor is where the selection started, the focus is the moving end where the
|
|
15
|
+
* caret sits. A collapsed selection (anchor === focus) is a plain caret.
|
|
16
|
+
*/
|
|
17
|
+
export type Selection = { anchor: number; focus: number }
|
|
18
|
+
|
|
19
|
+
export type MoveDirection = "left" | "right" | "start" | "end"
|
|
20
|
+
|
|
21
|
+
export type TextBufferOptions = {
|
|
22
|
+
/**
|
|
23
|
+
* Controlled value accessor. When it returns a string, the buffer mirrors it
|
|
24
|
+
* and edits flow out only through onInput (the internal text is bypassed).
|
|
25
|
+
* The selection is always buffer-owned editing state regardless.
|
|
26
|
+
*/
|
|
27
|
+
value?: () => string | undefined
|
|
28
|
+
/** Initial value when uncontrolled. */
|
|
29
|
+
defaultValue?: string
|
|
30
|
+
/** Called with the new text after every edit, already clamped to maxLength. */
|
|
31
|
+
onInput?: (value: string) => void
|
|
32
|
+
/** Max length; inserts past it are clamped. */
|
|
33
|
+
maxLength?: () => number | undefined
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export type TextBuffer = {
|
|
37
|
+
/** Current text: the controlled value if provided, else internal state. */
|
|
38
|
+
value(): string
|
|
39
|
+
/** Current selection, clamped to the text length. Collapsed = a caret. */
|
|
40
|
+
selection(): Selection
|
|
41
|
+
/** The focus offset (where the caret sits). */
|
|
42
|
+
caret(): number
|
|
43
|
+
/** Replace the current selection with text, then collapse the caret after it. */
|
|
44
|
+
insertText(text: string): void
|
|
45
|
+
/** Delete the selection if any, else the character before the caret. */
|
|
46
|
+
deleteBackward(): void
|
|
47
|
+
/** Delete the selection if any, else the character after the caret. */
|
|
48
|
+
deleteForward(): void
|
|
49
|
+
/** Move the caret. `extend` keeps the anchor to grow a selection (else collapses). */
|
|
50
|
+
move(direction: MoveDirection, options?: { extend?: boolean }): void
|
|
51
|
+
/** Set the selection directly (offsets are clamped to the text length). */
|
|
52
|
+
setSelection(anchor: number, focus: number): void
|
|
53
|
+
/** Replace the whole value, caret to the end. */
|
|
54
|
+
setValue(next: string): void
|
|
55
|
+
/** Clear to empty. */
|
|
56
|
+
clear(): void
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* An editable text buffer that bridges controlled/uncontrolled use and owns the
|
|
61
|
+
* caret/selection. With a `value` accessor the buffer is controlled: edits do
|
|
62
|
+
* not mutate internal text, they only call `onInput` so the owner can update its
|
|
63
|
+
* source. Without one it holds the text itself. The selection is always
|
|
64
|
+
* buffer-owned state and is clamped to the current text length on read, so an
|
|
65
|
+
* external truncation of a controlled value cannot leave the caret dangling.
|
|
66
|
+
* Every edit is clamped to `maxLength`.
|
|
67
|
+
*/
|
|
68
|
+
export function createTextBuffer(options: TextBufferOptions = {}): TextBuffer {
|
|
69
|
+
let initial = options.defaultValue ?? ""
|
|
70
|
+
let [internalValue, setInternalValue] = createSignal(initial)
|
|
71
|
+
let [selectionState, setSelectionState] = createSignal<Selection>({
|
|
72
|
+
anchor: initial.length,
|
|
73
|
+
focus: initial.length,
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
let value = () => options.value?.() ?? internalValue()
|
|
77
|
+
|
|
78
|
+
let selection = (): Selection => {
|
|
79
|
+
let len = value().length
|
|
80
|
+
let s = selectionState()
|
|
81
|
+
return { anchor: Math.min(s.anchor, len), focus: Math.min(s.focus, len) }
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Ordered selection bounds [start, end).
|
|
85
|
+
let range = () => {
|
|
86
|
+
let { anchor, focus } = selection()
|
|
87
|
+
return anchor <= focus ? [anchor, focus] : [focus, anchor]
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
let setCaret = (offset: number) => setSelectionState({ anchor: offset, focus: offset })
|
|
91
|
+
|
|
92
|
+
// Apply a text edit and place the caret, clamping to maxLength.
|
|
93
|
+
let apply = (next: string, caret: number) => {
|
|
94
|
+
let max = options.maxLength?.()
|
|
95
|
+
if (max != null && next.length > max) next = next.slice(0, max)
|
|
96
|
+
caret = Math.min(caret, next.length)
|
|
97
|
+
if (options.value?.() == null) setInternalValue(next)
|
|
98
|
+
setCaret(caret)
|
|
99
|
+
options.onInput?.(next)
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return {
|
|
103
|
+
value,
|
|
104
|
+
selection,
|
|
105
|
+
caret: () => selection().focus,
|
|
106
|
+
|
|
107
|
+
insertText: (text) => {
|
|
108
|
+
let v = value()
|
|
109
|
+
let [start, end] = range()
|
|
110
|
+
apply(v.slice(0, start) + text + v.slice(end), start + text.length)
|
|
111
|
+
},
|
|
112
|
+
|
|
113
|
+
deleteBackward: () => {
|
|
114
|
+
let v = value()
|
|
115
|
+
let [start, end] = range()
|
|
116
|
+
if (start !== end) apply(v.slice(0, start) + v.slice(end), start)
|
|
117
|
+
else if (start > 0) apply(v.slice(0, start - 1) + v.slice(start), start - 1)
|
|
118
|
+
},
|
|
119
|
+
|
|
120
|
+
deleteForward: () => {
|
|
121
|
+
let v = value()
|
|
122
|
+
let [start, end] = range()
|
|
123
|
+
if (start !== end) apply(v.slice(0, start) + v.slice(end), start)
|
|
124
|
+
else if (end < v.length) apply(v.slice(0, end) + v.slice(end + 1), end)
|
|
125
|
+
},
|
|
126
|
+
|
|
127
|
+
move: (direction, opts) => {
|
|
128
|
+
let extend = opts?.extend ?? false
|
|
129
|
+
let { anchor, focus } = selection()
|
|
130
|
+
let len = value().length
|
|
131
|
+
// A non-extending left/right on a range collapses to the near edge.
|
|
132
|
+
if (!extend && anchor !== focus && (direction === "left" || direction === "right")) {
|
|
133
|
+
setCaret(direction === "left" ? Math.min(anchor, focus) : Math.max(anchor, focus))
|
|
134
|
+
return
|
|
135
|
+
}
|
|
136
|
+
let next = focus
|
|
137
|
+
if (direction === "left") next = Math.max(0, focus - 1)
|
|
138
|
+
else if (direction === "right") next = Math.min(len, focus + 1)
|
|
139
|
+
else if (direction === "start") next = 0
|
|
140
|
+
else if (direction === "end") next = len
|
|
141
|
+
setSelectionState({ anchor: extend ? anchor : next, focus: next })
|
|
142
|
+
},
|
|
143
|
+
|
|
144
|
+
setSelection: (anchor, focus) => {
|
|
145
|
+
let len = value().length
|
|
146
|
+
setSelectionState({ anchor: Math.min(anchor, len), focus: Math.min(focus, len) })
|
|
147
|
+
},
|
|
148
|
+
|
|
149
|
+
setValue: (next) => apply(next, next.length),
|
|
150
|
+
clear: () => apply("", 0),
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export type CaretScrollInput = {
|
|
155
|
+
text: string
|
|
156
|
+
fontSize: number
|
|
157
|
+
/** Caret offset into `text`. Defaults to the text end. */
|
|
158
|
+
caret?: number
|
|
159
|
+
/** Px reserved so the caret stays visible at the viewport edge. Default 0. */
|
|
160
|
+
caretWidth?: number
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Returns the horizontal scroll offset that keeps the caret within the viewport
|
|
165
|
+
* node. The offset is retained between frames and only adjusted when the caret
|
|
166
|
+
* would fall outside the visible range (scrolled left when the caret runs past
|
|
167
|
+
* the right edge, right when it moves before the left edge), so stationary text
|
|
168
|
+
* does not jump. The viewport width and offset are computed in onLayout and the
|
|
169
|
+
* synchronous flush drains the update before paint, so the scroll tracks a caret
|
|
170
|
+
* or width change in the same frame. Pure geometry: no caret rendering and no
|
|
171
|
+
* placeholder/visual policy.
|
|
172
|
+
*/
|
|
173
|
+
export function createCaretScroll(
|
|
174
|
+
viewport: () => { id: number } | undefined,
|
|
175
|
+
input: () => CaretScrollInput,
|
|
176
|
+
): () => number {
|
|
177
|
+
let [scrollX, setScrollX] = createSignal(0)
|
|
178
|
+
|
|
179
|
+
onLayout(() => {
|
|
180
|
+
let node = viewport()
|
|
181
|
+
if (!node) return
|
|
182
|
+
let vw = getBoundingBox(node)?.width ?? 0
|
|
183
|
+
let { text, fontSize, caret, caretWidth = 0 } = input()
|
|
184
|
+
let len = text.length
|
|
185
|
+
let c = caret == null ? len : Math.max(0, Math.min(caret, len))
|
|
186
|
+
|
|
187
|
+
let totalWidth = measureText(text, { fontSize }).width
|
|
188
|
+
let caretX = c >= len ? totalWidth : measureText(text.slice(0, c), { fontSize }).width
|
|
189
|
+
let maxScroll = Math.max(0, totalWidth + caretWidth - vw)
|
|
190
|
+
|
|
191
|
+
let cur = scrollX()
|
|
192
|
+
let next = cur
|
|
193
|
+
if (vw <= 0) {
|
|
194
|
+
next = 0
|
|
195
|
+
} else if (caretX < cur) {
|
|
196
|
+
next = caretX
|
|
197
|
+
} else if (caretX + caretWidth > cur + vw) {
|
|
198
|
+
next = caretX + caretWidth - vw
|
|
199
|
+
}
|
|
200
|
+
next = Math.max(0, Math.min(next, maxScroll))
|
|
201
|
+
|
|
202
|
+
if (next !== cur) setScrollX(next)
|
|
203
|
+
flush()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
return scrollX
|
|
207
|
+
}
|
package/src/types.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/// <reference types="@solidrt/flux-types" />
|
|
2
2
|
|
|
3
3
|
import type { JSX as SolidJSX } from "@solidjs/signals"
|
|
4
|
+
import type { Gradient } from "./color"
|
|
4
5
|
|
|
5
6
|
// UI event bus (lattice), provided by the runtime as a builtin module.
|
|
6
7
|
// on/once return an unsubscribe function.
|
|
@@ -20,51 +21,26 @@ declare module "srt:dev" {
|
|
|
20
21
|
export function stop(): void
|
|
21
22
|
}
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
deleteNode(parentId: number, nodeId: number): void
|
|
32
|
-
setProperty(nodeId: number, name: string, value: unknown): void
|
|
33
|
-
setTextInputActive(active: boolean): void
|
|
34
|
-
requestFrame(): void
|
|
35
|
-
measureText(text: string, options?: MeasureTextOptions): { width: number, height: number }
|
|
36
|
-
getBoundingBox(id: number): { x: number, y: number, width: number, height: number } | null
|
|
37
|
-
}
|
|
24
|
+
// Frame draw (lattice runner). renderFrame() synchronously renders the current
|
|
25
|
+
// frame: layout, the postLayout hook, paint and hover refresh, then builds and
|
|
26
|
+
// submits the display list. To schedule a future frame instead, use
|
|
27
|
+
// requestFrame() from "flux:rendertree". The tree-building surface itself is
|
|
28
|
+
// "flux:rendertree" (from @solidrt/flux-types).
|
|
29
|
+
declare module "srt:render" {
|
|
30
|
+
export function renderFrame(): void
|
|
31
|
+
}
|
|
38
32
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
createMutableTexture(data: Uint8Array, width: number, height: number): number
|
|
42
|
-
uploadTexture(textureId: number, offset?: number): void
|
|
33
|
+
declare global {
|
|
34
|
+
let image: {
|
|
43
35
|
decodeImage(bytes: Uint8Array): { data: Uint8Array, width: number, height: number }
|
|
44
36
|
}
|
|
45
37
|
|
|
46
|
-
let camera: {
|
|
47
|
-
listCameras(): { id: number, name: string, facing: "front" | "back" | "unknown" }[]
|
|
48
|
-
open(options: { camera?: number, facing?: "front" | "back", width?: number, height?: number, scan?: string[] }):
|
|
49
|
-
Promise<{ handle: number, texture: number, width: number, height: number }>
|
|
50
|
-
setBarcodeCallback(handle: number, callback: (result: { data: string, format: "qr" }) => void): void
|
|
51
|
-
scanImage(data: Uint8Array, width: number, height: number): { data: string, format: "qr" }[]
|
|
52
|
-
close(handle: number): void
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
let microphone: {
|
|
56
|
-
listMicrophones(): { id: number, name: string }[]
|
|
57
|
-
open(options: { microphone?: number, sampleRate?: number }): { handle: number, sampleRate: number }
|
|
58
|
-
read(handle: number): Float32Array
|
|
59
|
-
close(handle: number): void
|
|
60
|
-
}
|
|
61
|
-
|
|
62
38
|
let speech: {
|
|
63
39
|
start(options: {
|
|
64
|
-
model: Uint8Array, vadModel: Uint8Array,
|
|
65
|
-
|
|
40
|
+
model: Uint8Array, vadModel: Uint8Array, lang?: string, microphone?: number,
|
|
41
|
+
continuous?: boolean, interimResults?: boolean, wakeWord?: Uint8Array | string | string[], wakeThreshold?: number,
|
|
66
42
|
}): Promise<{ handle: number }>
|
|
67
|
-
setResultCallback(handle: number, callback: (result: {
|
|
43
|
+
setResultCallback(handle: number, callback: (result: { transcript: string, isFinal: boolean }) => void): void
|
|
68
44
|
setSpeechStartCallback(handle: number, callback: () => void): void
|
|
69
45
|
setSpeechEndCallback(handle: number, callback: () => void): void
|
|
70
46
|
setWakeCallback(handle: number, callback: () => void): void
|
|
@@ -72,14 +48,6 @@ declare global {
|
|
|
72
48
|
}
|
|
73
49
|
}
|
|
74
50
|
|
|
75
|
-
export interface MeasureTextOptions {
|
|
76
|
-
fontFamily?: "sans" | "mono" | (string & {})
|
|
77
|
-
fontSize?: number
|
|
78
|
-
fontStyle?: "normal" | "italic"
|
|
79
|
-
fontWeight?: 100 | 200 | 300 | 400 | 500 | 600 | 700 | 800 | 900
|
|
80
|
-
maxLines?: number
|
|
81
|
-
}
|
|
82
|
-
|
|
83
51
|
type Children = SolidJSX.Element
|
|
84
52
|
|
|
85
53
|
interface FlexboxProps {
|
|
@@ -128,6 +96,7 @@ export interface LayoutProps extends FlexboxProps, GridProps {
|
|
|
128
96
|
minHeight?: Dimension
|
|
129
97
|
maxWidth?: Dimension
|
|
130
98
|
maxHeight?: Dimension
|
|
99
|
+
aspectRatio?: number | (string & {})
|
|
131
100
|
|
|
132
101
|
padding?: Dimension
|
|
133
102
|
paddingTop?: Dimension
|
|
@@ -146,11 +115,12 @@ export interface LayoutProps extends FlexboxProps, GridProps {
|
|
|
146
115
|
overflowY?: "visible" | "clip" | "hidden" | "scroll"
|
|
147
116
|
}
|
|
148
117
|
|
|
149
|
-
|
|
118
|
+
/** Colors are CSS color strings, parsed to a packed u32 by `parseColor`. */
|
|
150
119
|
export type Color = string
|
|
151
120
|
|
|
152
121
|
export interface PaintProps {
|
|
153
|
-
color
|
|
122
|
+
// A solid color, or a gradient from createLinearGradient/createRadialGradient.
|
|
123
|
+
color?: Color | Gradient
|
|
154
124
|
blendMode?: "clear" | "source" | "destination" | "source-over" | "destination-over" | "source-in" | "destination-in" | "source-out" | "destination-out" | "source-atop" | "destination-atop" | "xor" | "plus" | "modulate" | "screen" | "overlay" | "darken" | "lighten" | "color-dodge" | "color-burn" | "hard-light" | "soft-light" | "difference" | "exclusion" | "multiply" | "hue" | "saturation" | "color" | "luminosity"
|
|
155
125
|
drawStyle?: "fill" | "stroke" | "stroke-and-fill"
|
|
156
126
|
strokeCap?: "butt" | "round" | "square"
|
|
@@ -288,7 +258,15 @@ export interface LineProps extends PaintProps, PointerProps {
|
|
|
288
258
|
|
|
289
259
|
export interface PathProps extends Position, PaintProps, PointerProps {
|
|
290
260
|
d?: string
|
|
291
|
-
fillRule?: "
|
|
261
|
+
fillRule?: "nonzero" | "evenodd"
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export interface SvgProps extends Position, PointerProps {
|
|
265
|
+
// A whole SVG document as a string (an imported asset, a fetched string, or a
|
|
266
|
+
// template literal). Parsed and rendered as one unit; takes no JSX children.
|
|
267
|
+
src?: string
|
|
268
|
+
// Drives currentColor in the document. Explicit fills/strokes still win.
|
|
269
|
+
color?: Color
|
|
292
270
|
}
|
|
293
271
|
|
|
294
272
|
export interface TextProps extends PaintProps, PointerProps {
|