electrobun 1.18.4-beta.18 → 1.18.4-beta.21

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.
Files changed (55) hide show
  1. package/README.md +9 -0
  2. package/bin/electrobun.cjs +165 -0
  3. package/dist/api/browser/ui/__tests__/dom.test.ts +473 -0
  4. package/dist/api/browser/ui/__tests__/domStub.ts +218 -0
  5. package/dist/api/browser/ui/dom.ts +490 -0
  6. package/dist/api/browser/ui/index.ts +44 -0
  7. package/dist/api/browser/ui/jsx-dev-runtime.ts +16 -0
  8. package/dist/api/browser/ui/jsx-runtime.ts +56 -0
  9. package/dist/api/config/ElectrobunConfig.ts +33 -0
  10. package/dist/api/preload/.generated/compiled.ts +1 -1
  11. package/dist/api/preload/index.ts +2 -0
  12. package/dist/api/preload/uiTag.ts +45 -0
  13. package/dist/api/sdks/main/__tests__/utils-quit-exit-code.test.ts +44 -0
  14. package/dist/api/sdks/main/core/GpuWindow.ts +19 -0
  15. package/dist/api/sdks/main/core/Utils.ts +52 -4
  16. package/dist/api/sdks/main/core/WGPUView.ts +9 -0
  17. package/dist/api/sdks/main/entries/ui.ts +1 -0
  18. package/dist/api/sdks/main/proc/native.ts +187 -0
  19. package/dist/api/sdks/main/ui/__tests__/font.test.ts +49 -0
  20. package/dist/api/sdks/main/ui/__tests__/hit.test.ts +64 -0
  21. package/dist/api/sdks/main/ui/__tests__/jsx.test.ts +252 -0
  22. package/dist/api/sdks/main/ui/__tests__/layout.test.ts +159 -0
  23. package/dist/api/sdks/main/ui/__tests__/paint.test.ts +115 -0
  24. package/dist/api/sdks/main/ui/__tests__/reactive.test.ts +456 -0
  25. package/dist/api/sdks/main/ui/__tests__/scroll-focus-input.test.ts +298 -0
  26. package/dist/api/sdks/main/ui/__tests__/tree.test.ts +96 -0
  27. package/dist/api/sdks/main/ui/__tests__/ui.test.ts +170 -0
  28. package/dist/api/sdks/main/ui/elements.ts +135 -0
  29. package/dist/api/sdks/main/ui/font.ts +168 -0
  30. package/dist/api/sdks/main/ui/hit.ts +46 -0
  31. package/dist/api/sdks/main/ui/index.ts +71 -0
  32. package/dist/api/sdks/main/ui/input.ts +268 -0
  33. package/dist/api/sdks/main/ui/jsx-dev-runtime.ts +16 -0
  34. package/dist/api/sdks/main/ui/jsx-runtime.ts +136 -0
  35. package/dist/api/sdks/main/ui/keymap.ts +147 -0
  36. package/dist/api/sdks/main/ui/layout.ts +178 -0
  37. package/dist/api/sdks/main/ui/paint.ts +196 -0
  38. package/dist/api/sdks/main/ui/reactive.ts +4 -0
  39. package/dist/api/sdks/main/ui/renderer.ts +278 -0
  40. package/dist/api/sdks/main/ui/text.ts +175 -0
  41. package/dist/api/sdks/main/ui/textInput.ts +121 -0
  42. package/dist/api/sdks/main/ui/tree.ts +276 -0
  43. package/dist/api/sdks/main/ui/ui.ts +457 -0
  44. package/dist/api/sdks/main/ui/uiTagHost.ts +56 -0
  45. package/dist/api/sdks/main/ui/uiwindow.ts +330 -0
  46. package/dist/api/shared/build-dependencies.test.ts +1 -1
  47. package/dist/api/shared/build-dependencies.ts +4 -4
  48. package/dist/api/shared/linux-webkit-automation.test.ts +1 -1
  49. package/dist/api/shared/warren/jsx.ts +279 -0
  50. package/dist/api/shared/warren/reactive.ts +638 -0
  51. package/dist/api/shared/windows-unicode-ui.test.ts +4 -4
  52. package/dist/preload-full.js +35 -0
  53. package/dist/zig-sdk/electrobun.zig +197 -162
  54. package/{dash.config.ts → hutch.config.ts} +4 -3
  55. package/package.json +14 -2
@@ -0,0 +1,278 @@
1
+ // Dawn renderer: one instanced-quad pipeline drawing the whole command
2
+ // buffer in a single draw call. Rounded corners via SDF with ~1px AA.
3
+ // Uses Electrobun's browser-style WebGPU adapter, so this reads like
4
+ // standard WebGPU.
5
+
6
+ import webgpu from "../webgpuAdapter";
7
+ import type { GpuWindow } from "../core/GpuWindow";
8
+ import type { WGPUView } from "../core/WGPUView";
9
+ import { FLOATS_PER_INSTANCE, parseColor, type PaintBuffer } from "./paint";
10
+ import { ATLAS_SIZE, textAtlas } from "./text";
11
+
12
+ const SHADER = /* wgsl */ `
13
+ struct Uniforms {
14
+ viewport: vec4<f32>,
15
+ };
16
+ @group(0) @binding(0) var<uniform> u: Uniforms;
17
+
18
+ struct VSOut {
19
+ @builtin(position) pos: vec4<f32>,
20
+ @location(0) color: vec4<f32>,
21
+ @location(1) local: vec2<f32>,
22
+ @location(2) halfSize: vec2<f32>,
23
+ @location(3) radius: f32,
24
+ @location(4) clip: vec4<f32>,
25
+ @location(5) uv: vec2<f32>,
26
+ @location(6) textured: f32,
27
+ };
28
+
29
+ @vertex
30
+ fn vs(
31
+ @builtin(vertex_index) vi: u32,
32
+ @location(0) rect: vec4<f32>,
33
+ @location(1) color: vec4<f32>,
34
+ @location(2) misc: vec4<f32>,
35
+ @location(3) clipRect: vec4<f32>,
36
+ @location(4) uvRect: vec4<f32>,
37
+ ) -> VSOut {
38
+ var corners = array<vec2<f32>, 6>(
39
+ vec2<f32>(0.0, 0.0), vec2<f32>(1.0, 0.0), vec2<f32>(0.0, 1.0),
40
+ vec2<f32>(0.0, 1.0), vec2<f32>(1.0, 0.0), vec2<f32>(1.0, 1.0),
41
+ );
42
+ let c = corners[vi];
43
+ let px = rect.xy + c * rect.zw;
44
+ let ndc = vec2<f32>(
45
+ px.x / u.viewport.x * 2.0 - 1.0,
46
+ 1.0 - px.y / u.viewport.y * 2.0,
47
+ );
48
+ var out: VSOut;
49
+ out.pos = vec4<f32>(ndc, 0.0, 1.0);
50
+ out.color = color;
51
+ out.halfSize = rect.zw * 0.5;
52
+ out.local = (c - vec2<f32>(0.5, 0.5)) * rect.zw;
53
+ out.radius = misc.x;
54
+ out.clip = clipRect;
55
+ out.uv = mix(uvRect.xy, uvRect.zw, c);
56
+ out.textured = misc.y;
57
+ return out;
58
+ }
59
+
60
+ fn sdRoundRect(p: vec2<f32>, b: vec2<f32>, r: f32) -> f32 {
61
+ let q = abs(p) - b + vec2<f32>(r, r);
62
+ return length(max(q, vec2<f32>(0.0, 0.0))) + min(max(q.x, q.y), 0.0) - r;
63
+ }
64
+
65
+ @group(0) @binding(1) var atlasSampler: sampler;
66
+ @group(0) @binding(2) var atlasTexture: texture_2d<f32>;
67
+
68
+ @fragment
69
+ fn fs(in: VSOut) -> @location(0) vec4<f32> {
70
+ // Sample before any branching: WGSL requires textureSample (implicit
71
+ // derivatives) in uniform control flow.
72
+ let sampled = textureSample(atlasTexture, atlasSampler, in.uv);
73
+ // Scissor to the instance's clip rect (framebuffer pixel space).
74
+ if (in.pos.x < in.clip.x || in.pos.y < in.clip.y
75
+ || in.pos.x >= in.clip.x + in.clip.z || in.pos.y >= in.clip.y + in.clip.w) {
76
+ discard;
77
+ }
78
+ var coverage: f32;
79
+ if (in.textured > 0.5) {
80
+ // Glyphs are rasterized white-on-transparent: alpha is coverage.
81
+ coverage = sampled.a;
82
+ } else {
83
+ let r = clamp(in.radius, 0.0, min(in.halfSize.x, in.halfSize.y));
84
+ let d = sdRoundRect(in.local, in.halfSize, r);
85
+ coverage = 1.0 - smoothstep(-0.75, 0.75, d);
86
+ }
87
+ if (coverage <= 0.003) {
88
+ discard;
89
+ }
90
+ return vec4<f32>(in.color.rgb, in.color.a * coverage);
91
+ }
92
+ `;
93
+
94
+ export interface UiRenderer {
95
+ render(buffer: PaintBuffer, width: number, height: number): void;
96
+ resize(width: number, height: number): void;
97
+ }
98
+
99
+ /**
100
+ * Renderer over any Dawn target: a full-window GpuWindow or an individual
101
+ * WGPUView (used when a UI tree renders into a view composited over a
102
+ * webview). Size is pushed explicitly via resize(); the surface reconfigures
103
+ * on the next frame.
104
+ */
105
+ export async function createUiRenderer(
106
+ target: GpuWindow | WGPUView,
107
+ clearColor: string | number,
108
+ initialSize: { width: number; height: number },
109
+ ): Promise<UiRenderer> {
110
+ const { context: ctx } = webgpu.createContext(target as any);
111
+ ctx._fallbackSize = { ...initialSize };
112
+ const adapter = await webgpu.navigator.requestAdapter({
113
+ compatibleSurface: ctx,
114
+ });
115
+ const device = await adapter.requestDevice();
116
+ // Non-sRGB surface (command-buffer colors are already display-referred).
117
+ // Premultiplied alpha lets transparent windows composite; configure falls
118
+ // back to the surface's supported mode when unavailable, and our blend
119
+ // state produces premultiplied output over the alpha clear either way.
120
+ ctx.configure({ device, format: "bgra8unorm", alphaMode: "premultiplied" });
121
+
122
+ const clear = parseColor(clearColor) >>> 0;
123
+ const clearValue = {
124
+ r: ((clear >>> 24) & 0xff) / 255,
125
+ g: ((clear >>> 16) & 0xff) / 255,
126
+ b: ((clear >>> 8) & 0xff) / 255,
127
+ a: (clear & 0xff) / 255,
128
+ };
129
+
130
+ const module = device.createShaderModule({ code: SHADER });
131
+ const pipeline = device.createRenderPipeline({
132
+ layout: "auto",
133
+ vertex: {
134
+ module,
135
+ entryPoint: "vs",
136
+ buffers: [
137
+ {
138
+ arrayStride: FLOATS_PER_INSTANCE * 4,
139
+ stepMode: "instance",
140
+ attributes: [
141
+ { shaderLocation: 0, offset: 0, format: "float32x4" },
142
+ { shaderLocation: 1, offset: 16, format: "float32x4" },
143
+ { shaderLocation: 2, offset: 32, format: "float32x4" },
144
+ { shaderLocation: 3, offset: 48, format: "float32x4" },
145
+ { shaderLocation: 4, offset: 64, format: "float32x4" },
146
+ ],
147
+ },
148
+ ],
149
+ },
150
+ fragment: {
151
+ module,
152
+ entryPoint: "fs",
153
+ targets: [
154
+ {
155
+ format: ctx.format,
156
+ blend: {
157
+ color: {
158
+ operation: "add",
159
+ srcFactor: "src-alpha",
160
+ dstFactor: "one-minus-src-alpha",
161
+ },
162
+ alpha: {
163
+ operation: "add",
164
+ srcFactor: "one",
165
+ dstFactor: "one-minus-src-alpha",
166
+ },
167
+ },
168
+ },
169
+ ],
170
+ },
171
+ primitive: { topology: "triangle-list" },
172
+ });
173
+
174
+ const uniformBuffer = device.createBuffer({
175
+ size: 16,
176
+ usage: 0x40 | 0x8, // UNIFORM | COPY_DST
177
+ });
178
+ const atlasTexture = device.createTexture({
179
+ size: { width: ATLAS_SIZE, height: ATLAS_SIZE },
180
+ format: "rgba8unorm",
181
+ usage: 0x4 | 0x2, // TEXTURE_BINDING | COPY_DST
182
+ });
183
+ const atlasView = atlasTexture.createView();
184
+ const atlasSampler = device.createSampler({
185
+ magFilter: "linear",
186
+ minFilter: "linear",
187
+ });
188
+ const bindGroup = device.createBindGroup({
189
+ layout: pipeline.getBindGroupLayout(0),
190
+ entries: [
191
+ { binding: 0, resource: { buffer: uniformBuffer } },
192
+ { binding: 1, resource: atlasSampler },
193
+ { binding: 2, resource: atlasView },
194
+ ],
195
+ });
196
+
197
+ let atlasGeneration = -1;
198
+ const flushAtlas = () => {
199
+ const dirty = textAtlas.takeDirty();
200
+ const generationChanged = textAtlas.generation !== atlasGeneration;
201
+ if (!dirty && !generationChanged) return;
202
+ atlasGeneration = textAtlas.generation;
203
+ const region = generationChanged
204
+ ? { x0: 0, y0: 0, x1: ATLAS_SIZE, y1: ATLAS_SIZE }
205
+ : dirty!;
206
+ const x = Math.max(0, Math.floor(region.x0));
207
+ const y = Math.max(0, Math.floor(region.y0));
208
+ const w = Math.min(ATLAS_SIZE, Math.ceil(region.x1)) - x;
209
+ const h = Math.min(ATLAS_SIZE, Math.ceil(region.y1)) - y;
210
+ if (w <= 0 || h <= 0) return;
211
+ // Pack the dirty rows into a tight buffer for the upload.
212
+ const packed = new Uint8Array(w * h * 4);
213
+ for (let row = 0; row < h; row++) {
214
+ const src = ((y + row) * ATLAS_SIZE + x) * 4;
215
+ packed.set(textAtlas.pixels.subarray(src, src + w * 4), row * w * 4);
216
+ }
217
+ device.queue.writeTexture(
218
+ { texture: atlasTexture, origin: { x, y } },
219
+ packed,
220
+ { bytesPerRow: w * 4, rowsPerImage: h },
221
+ { width: w, height: h },
222
+ );
223
+ };
224
+
225
+ let instanceCapacity = 1024;
226
+ let instanceBuffer = device.createBuffer({
227
+ size: instanceCapacity * FLOATS_PER_INSTANCE * 4,
228
+ usage: 0x20 | 0x8, // VERTEX | COPY_DST
229
+ });
230
+
231
+ return {
232
+ resize(width: number, height: number) {
233
+ ctx._fallbackSize = { width, height };
234
+ },
235
+ render(buffer: PaintBuffer, width: number, height: number) {
236
+ if (width <= 0 || height <= 0) return;
237
+ flushAtlas();
238
+ if (buffer.count > instanceCapacity) {
239
+ while (instanceCapacity < buffer.count) instanceCapacity *= 2;
240
+ instanceBuffer = device.createBuffer({
241
+ size: instanceCapacity * FLOATS_PER_INSTANCE * 4,
242
+ usage: 0x20 | 0x8,
243
+ });
244
+ }
245
+ device.queue.writeBuffer(
246
+ uniformBuffer,
247
+ 0,
248
+ new Float32Array([width, height, 0, 0]),
249
+ );
250
+ if (buffer.count > 0) {
251
+ device.queue.writeBuffer(
252
+ instanceBuffer,
253
+ 0,
254
+ buffer.data.subarray(0, buffer.count * FLOATS_PER_INSTANCE),
255
+ );
256
+ }
257
+ const encoder = device.createCommandEncoder();
258
+ const pass = encoder.beginRenderPass({
259
+ colorAttachments: [
260
+ {
261
+ view: ctx.getCurrentTexture().createView(),
262
+ loadOp: "clear",
263
+ storeOp: "store",
264
+ clearValue,
265
+ },
266
+ ],
267
+ });
268
+ pass.setPipeline(pipeline);
269
+ pass.setBindGroup(0, bindGroup);
270
+ pass.setVertexBuffer(0, instanceBuffer);
271
+ if (buffer.count > 0) {
272
+ pass.draw(6, buffer.count);
273
+ }
274
+ pass.end();
275
+ device.queue.submit([encoder.finish()]);
276
+ },
277
+ };
278
+ }
@@ -0,0 +1,175 @@
1
+ // Text backend for the UI runtime. Two implementations behind one interface:
2
+ //
3
+ // - bitmap (default): the built-in 5x7 font, pure — used in headless tests
4
+ // and wherever the native wrapper lacks the CoreText exports.
5
+ // - native: system-font measurement + rasterization into a shared glyph
6
+ // atlas, activated by mounts via tryEnableNativeText().
7
+ //
8
+ // Paint consults the backend for measurement and (in native mode) atlas
9
+ // entries; the renderer uploads dirty atlas regions and samples them for
10
+ // textured instances.
11
+
12
+ import { measureText as measureBitmap } from "./font";
13
+
14
+ export interface AtlasEntry {
15
+ // Normalized UV rect in the atlas.
16
+ u0: number;
17
+ v0: number;
18
+ u1: number;
19
+ v1: number;
20
+ // Logical size of the rasterized string in points.
21
+ w: number;
22
+ h: number;
23
+ }
24
+
25
+ export const ATLAS_SIZE = 2048;
26
+ /** Device pixels per point used when rasterizing (crisp on 2x displays). */
27
+ export const ATLAS_SCALE = 2;
28
+
29
+ interface NativeTextApi {
30
+ measure(
31
+ text: string,
32
+ fontName: string,
33
+ size: number,
34
+ ): { w: number; h: number; ascent: number };
35
+ rasterize(
36
+ text: string,
37
+ fontName: string,
38
+ size: number,
39
+ scale: number,
40
+ ): { width: number; height: number; data: Uint8Array } | null;
41
+ }
42
+
43
+ let nativeApi: NativeTextApi | null = null;
44
+ const measureCache = new Map<string, { w: number; h: number }>();
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Atlas: shelf packer over a CPU-side RGBA buffer. On overflow, the whole
48
+ // atlas resets (generation bump) and visible strings re-enter on next paint.
49
+ // ---------------------------------------------------------------------------
50
+
51
+ class TextAtlas {
52
+ pixels = new Uint8Array(ATLAS_SIZE * ATLAS_SIZE * 4);
53
+ entries = new Map<string, AtlasEntry>();
54
+ generation = 0;
55
+ private shelfX = 0;
56
+ private shelfY = 0;
57
+ private shelfHeight = 0;
58
+ private dirty: { x0: number; y0: number; x1: number; y1: number } | null =
59
+ null;
60
+
61
+ takeDirty() {
62
+ const d = this.dirty;
63
+ this.dirty = null;
64
+ return d;
65
+ }
66
+
67
+ private markDirty(x: number, y: number, w: number, h: number) {
68
+ if (!this.dirty) {
69
+ this.dirty = { x0: x, y0: y, x1: x + w, y1: y + h };
70
+ return;
71
+ }
72
+ this.dirty.x0 = Math.min(this.dirty.x0, x);
73
+ this.dirty.y0 = Math.min(this.dirty.y0, y);
74
+ this.dirty.x1 = Math.max(this.dirty.x1, x + w);
75
+ this.dirty.y1 = Math.max(this.dirty.y1, y + h);
76
+ }
77
+
78
+ private reset() {
79
+ this.entries.clear();
80
+ this.pixels.fill(0);
81
+ this.shelfX = 0;
82
+ this.shelfY = 0;
83
+ this.shelfHeight = 0;
84
+ this.generation++;
85
+ this.markDirty(0, 0, ATLAS_SIZE, ATLAS_SIZE);
86
+ }
87
+
88
+ get(key: string, text: string, size: number): AtlasEntry | null {
89
+ const existing = this.entries.get(key);
90
+ if (existing) return existing;
91
+ if (!nativeApi) return null;
92
+ const raster = nativeApi.rasterize(text, "", size, ATLAS_SCALE);
93
+ if (!raster || raster.width === 0) return null;
94
+ const pad = 1; // guard against sampling bleed
95
+ const w = raster.width + pad * 2;
96
+ const h = raster.height + pad * 2;
97
+ if (w > ATLAS_SIZE || h > ATLAS_SIZE) return null;
98
+
99
+ if (this.shelfX + w > ATLAS_SIZE) {
100
+ this.shelfY += this.shelfHeight;
101
+ this.shelfX = 0;
102
+ this.shelfHeight = 0;
103
+ }
104
+ if (this.shelfY + h > ATLAS_SIZE) {
105
+ this.reset();
106
+ }
107
+ const x = this.shelfX + pad;
108
+ const y = this.shelfY + pad;
109
+ this.shelfX += w;
110
+ this.shelfHeight = Math.max(this.shelfHeight, h);
111
+
112
+ // Blit the rasterized rows into the atlas buffer.
113
+ for (let row = 0; row < raster.height; row++) {
114
+ const src = row * raster.width * 4;
115
+ const dst = ((y + row) * ATLAS_SIZE + x) * 4;
116
+ this.pixels.set(raster.data.subarray(src, src + raster.width * 4), dst);
117
+ }
118
+ this.markDirty(x - pad, y - pad, w, h);
119
+
120
+ const measured = measure(text, size);
121
+ const entry: AtlasEntry = {
122
+ u0: x / ATLAS_SIZE,
123
+ v0: y / ATLAS_SIZE,
124
+ u1: (x + raster.width) / ATLAS_SIZE,
125
+ v1: (y + raster.height) / ATLAS_SIZE,
126
+ w: measured.w,
127
+ h: measured.h,
128
+ };
129
+ this.entries.set(key, entry);
130
+ return entry;
131
+ }
132
+ }
133
+
134
+ export const textAtlas = new TextAtlas();
135
+
136
+ // ---------------------------------------------------------------------------
137
+ // Public backend surface
138
+ // ---------------------------------------------------------------------------
139
+
140
+ /** Switch to the native (system font) backend. Returns whether it's active. */
141
+ export function tryEnableNativeText(api: NativeTextApi | null): boolean {
142
+ if (nativeApi) return true;
143
+ if (!api) return false;
144
+ nativeApi = api;
145
+ measureCache.clear();
146
+ return true;
147
+ }
148
+
149
+ export function isNativeTextActive(): boolean {
150
+ return nativeApi !== null;
151
+ }
152
+
153
+ /** Force the pure bitmap backend (tests). */
154
+ export function resetTextBackend(): void {
155
+ nativeApi = null;
156
+ measureCache.clear();
157
+ }
158
+
159
+ export function measure(text: string, size: number): { w: number; h: number } {
160
+ if (!nativeApi) return measureBitmap(text, size);
161
+ const key = `${size}|${text}`;
162
+ const cached = measureCache.get(key);
163
+ if (cached) return cached;
164
+ const m = nativeApi.measure(text, "", size);
165
+ const result = { w: m.w, h: m.h };
166
+ if (measureCache.size > 10_000) measureCache.clear();
167
+ measureCache.set(key, result);
168
+ return result;
169
+ }
170
+
171
+ /** Atlas entry for a string (native backend only). */
172
+ export function atlasEntry(text: string, size: number): AtlasEntry | null {
173
+ if (!nativeApi || text.length === 0) return null;
174
+ return textAtlas.get(`${size}|${text}`, text, size);
175
+ }
@@ -0,0 +1,121 @@
1
+ // Controlled single-line text input built from the core primitives: a
2
+ // focusable box containing [before-caret text][caret][after-caret text].
3
+ // Editing runs through the pure applyEditKey reducer; the caret blinks only
4
+ // while focused, so idle inputs stay invalidation-free.
5
+
6
+ import { cleanup, inert, live, liveScope, signal } from "./reactive";
7
+ import { getUiContext, read, ui, type KeyEventInfo, type Reactive } from "./ui";
8
+ import { applyEditKey } from "./keymap";
9
+
10
+ export interface TextInputProps {
11
+ value: () => string;
12
+ onInput: (next: string) => void;
13
+ /** Enter. */
14
+ onSubmit?: (value: string) => void;
15
+ placeholder?: string;
16
+ autofocus?: boolean;
17
+ size?: number;
18
+ grow?: Reactive<number>;
19
+ width?: Reactive<number>;
20
+ pad?: Reactive<number>;
21
+ radius?: Reactive<number>;
22
+ bg?: Reactive<string | number>;
23
+ color?: string;
24
+ placeholderColor?: string;
25
+ caretColor?: string;
26
+ border?: Reactive<number>;
27
+ borderColor?: Reactive<string | number>;
28
+ focusBorderColor?: Reactive<string | number>;
29
+ }
30
+
31
+ export function textInput(props: TextInputProps): number {
32
+ const ctx = getUiContext();
33
+ const size = props.size ?? 14;
34
+ const color = props.color ?? "#e4e4f0";
35
+ const placeholderColor = props.placeholderColor ?? "#616178";
36
+ const caretColor = props.caretColor ?? "#e4e4f0";
37
+
38
+ const [caret, setCaret] = signal(inert(props.value).length);
39
+ const [blinkOn, setBlinkOn] = signal(true);
40
+
41
+ let id = 0;
42
+ const focused = () => ctx.focusedId() === id;
43
+
44
+ const handleKey = (e: KeyEventInfo): boolean => {
45
+ const value = inert(props.value);
46
+ // applyEditKey clamps the caret itself; String.slice clamps in render.
47
+ const result = applyEditKey(
48
+ { value, caret: inert(caret) },
49
+ e.keyCode,
50
+ e.modifiers,
51
+ e.chars,
52
+ );
53
+ if (!result.handled) return false;
54
+ if (result.submit) {
55
+ props.onSubmit?.(value);
56
+ return true;
57
+ }
58
+ setCaret(result.caret);
59
+ setBlinkOn(true);
60
+ if (result.value !== value) props.onInput(result.value);
61
+ return true;
62
+ };
63
+
64
+ id = ui.row(
65
+ {
66
+ focusable: true,
67
+ onKeyDown: handleKey,
68
+ onClick: () => setCaret(inert(props.value).length),
69
+ grow: props.grow,
70
+ width: props.width,
71
+ pad: props.pad ?? 10,
72
+ radius: props.radius ?? 8,
73
+ align: "center",
74
+ bg: props.bg ?? "#1b1b28",
75
+ border: props.border ?? 1,
76
+ borderColor: live(() =>
77
+ focused() && props.focusBorderColor !== undefined
78
+ ? read(props.focusBorderColor)
79
+ : read(props.borderColor ?? "#262638"),
80
+ ),
81
+ },
82
+ () => {
83
+ // Placeholder: an empty text node has zero width, so this only
84
+ // occupies space while the value is empty.
85
+ ui.text(
86
+ live(() =>
87
+ props.value().length === 0 ? props.placeholder ?? "" : "",
88
+ ),
89
+ { size, color: placeholderColor },
90
+ );
91
+ ui.text(live(() => props.value().slice(0, caret())), {
92
+ size,
93
+ color,
94
+ });
95
+ ui.box({
96
+ width: Math.max(1.5, size / 9),
97
+ height: size + 2,
98
+ bg: live(() =>
99
+ focused() && blinkOn() ? caretColor : "#00000000",
100
+ ),
101
+ });
102
+ ui.text(live(() => props.value().slice(caret())), { size, color });
103
+ },
104
+ );
105
+
106
+ // Blink only while focused; an idle unfocused input never dirties the tree.
107
+ liveScope(() => {
108
+ if (!focused()) {
109
+ setBlinkOn(true);
110
+ return;
111
+ }
112
+ const timer = setInterval(() => setBlinkOn((v) => !v), 530);
113
+ cleanup(() => clearInterval(timer));
114
+ });
115
+
116
+ if (props.autofocus) {
117
+ ctx.setFocused(id);
118
+ }
119
+
120
+ return id;
121
+ }