@niivue/uikit 1.0.0-rc.3 → 1.0.0-rc.30

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 (41) hide show
  1. package/README.md +705 -8
  2. package/dist/button.d.ts +97 -0
  3. package/dist/buttonOverlay.d.ts +121 -0
  4. package/dist/colorControl.d.ts +130 -0
  5. package/dist/colorControlOverlay.d.ts +72 -0
  6. package/dist/controls.d.ts +151 -0
  7. package/dist/dialog.d.ts +138 -0
  8. package/dist/dialogOverlay.d.ts +115 -0
  9. package/dist/filePicker.d.ts +95 -0
  10. package/dist/filePickerOverlay.d.ts +75 -0
  11. package/dist/host.d.ts +97 -0
  12. package/dist/index.d.ts +61 -0
  13. package/dist/menu.d.ts +227 -0
  14. package/dist/menuOverlay.d.ts +149 -0
  15. package/dist/numberInput.d.ts +172 -0
  16. package/dist/numberInputOverlay.d.ts +131 -0
  17. package/dist/pointLabel.d.ts +68 -0
  18. package/dist/pointLabelOverlay.d.ts +39 -0
  19. package/dist/rect.d.ts +27 -0
  20. package/dist/rectOverlay.d.ts +13 -0
  21. package/dist/render/glRectRenderer.d.ts +13 -0
  22. package/dist/render/shaders.d.ts +3 -0
  23. package/dist/render/wgpuRectRenderer.d.ts +18 -0
  24. package/dist/scroll.d.ts +70 -0
  25. package/dist/segmented.d.ts +127 -0
  26. package/dist/segmentedOverlay.d.ts +87 -0
  27. package/dist/select.d.ts +66 -0
  28. package/dist/selectOverlay.d.ts +102 -0
  29. package/dist/slider.d.ts +152 -0
  30. package/dist/sliderOverlay.d.ts +92 -0
  31. package/dist/text/layout.d.ts +8 -0
  32. package/dist/textArea.d.ts +216 -0
  33. package/dist/textAreaOverlay.d.ts +145 -0
  34. package/dist/textEdit.d.ts +72 -0
  35. package/dist/textField.d.ts +33 -0
  36. package/dist/textInput.d.ts +123 -0
  37. package/dist/textInputOverlay.d.ts +139 -0
  38. package/dist/toggle.d.ts +115 -0
  39. package/dist/toggleOverlay.d.ts +80 -0
  40. package/dist/uikit.js +6274 -216
  41. package/package.json +3 -3
package/README.md CHANGED
@@ -1,12 +1,15 @@
1
1
  # @niivue/uikit
2
2
 
3
- UIKit: a collection of controls and widgets (rulers, crosshairs, annotations,
4
- and later buttons, sliders, panels) integrated into the
3
+ UIKit: a collection of controls and widgets (rulers, crosshairs and
4
+ annotations, plus an on-canvas control layer of buttons, toggles, sliders,
5
+ menus, selects, segmented controls, number and text fields, color controls,
6
+ file pickers and dialogs) integrated into the
5
7
  [NiiVue](https://github.com/niivue/niivue) rendering lifecycle.
6
8
 
7
9
  > **Status: shipping widgets.** The rendering-lifecycle hook, UIKit's own
8
- > line/text renderers (WebGL2 + WebGPU, with a text transform), and the first
9
- > widgets (ruler, annotation, crosshair) are in. See `docs/ruler-port.md` in
10
+ > line/text/rect renderers (WebGL2 + WebGPU, with a text transform), the
11
+ > measuring widgets (ruler, annotation, crosshair) and the control layer with
12
+ > every widget in the table below are in. See `docs/ruler-port.md` in
10
13
  > `@niivue/niivue` for the design.
11
14
 
12
15
  ## Design in one paragraph
@@ -22,13 +25,34 @@ proven, core's overlays cut over onto it and the duplicate in core is removed.
22
25
 
23
26
  Each widget is a pure geometry builder (spec in, plain line + text draw data out,
24
27
  unit-testable without a GPU) paired with an overlay that owns the GPU resources
25
- and draws that data through the lifecycle hook.
28
+ and draws that data through the lifecycle hook. The overlays are the API. Of
29
+ the builders, only the measuring widgets' and the primitives' (`buildRuler`,
30
+ `buildCrosshair`, `buildAnnotationGeometry`, `buildRect`, `buildLine`,
31
+ `buildTerminatedLine`) are exported; the control widgets' models are
32
+ package-private (see "Conventions for contributors").
26
33
 
27
- | Builder | Overlay | Draws |
34
+ | Model | Overlay | Draws |
28
35
  | --- | --- | --- |
29
36
  | `buildRuler` | `UIKitRulerOverlay` | A measuring ruler with graduated ticks and a distance label |
30
37
  | `buildCrosshair` | `UIKitCrosshairOverlay` | A screen-space cross marking a point, optionally graduated and numbered |
31
38
  | `buildAnnotationGeometry` | `UIKitAnnotationOverlay` | Free-form annotation lines |
39
+ | `buildRect` | `UIKitRectOverlay` | Filled rounded rectangles with an optional border ring (the first filled primitive) |
40
+ | `buildButton` | `UIKitButtonOverlay` | Clickable, stylable push buttons with a text label and a press animation |
41
+ | `buildToggle` | `UIKitToggleOverlay` | Check boxes with a label: pointer or Space/Enter flips them |
42
+ | `buildSlider` | `UIKitSliderOverlay` | Horizontal sliders with min/max/step, ticks, a value readout, drag and arrow keys |
43
+ | `buildNumberInput` | `UIKitNumberInputOverlay` | Number fields: typed entry with validation, a spinner, arrow keys and the wheel |
44
+ | `buildTextInput` | `UIKitTextInputOverlay` | Single-line text fields with a placeholder, selection, Enter to submit |
45
+ | `buildTextArea` | `UIKitTextAreaOverlay` | Multi-line text fields that soft-wrap and scroll by whole rows |
46
+ | `buildColorControl` | `UIKitColorControlOverlay` | Color pickers the legacy way: a preview, R/G/B(/A) channel sliders and palette swatches |
47
+ | `buildFilePicker` | `UIKitFilePickerOverlay` | File pickers: a Choose button and a readout; the host chooser and downloads go through bridges |
48
+ | `buildDialog` | `UIKitDialogOverlay` | Modal dialogs: a scrim, a panel with a title, wrapped message, hosted widgets and action buttons |
49
+ | `buildMenuButton`, `buildMenuPopup` | `UIKitMenuOverlay` | Menu buttons with popups of actions, check items and radio groups |
50
+ | `selectMenuSpec` | `UIKitSelectOverlay` | Drop-down lists: a button showing the chosen option over a popup of options |
51
+ | `buildSegmented` | `UIKitSegmentedOverlay` | Segmented controls: one row of segments with exactly one selected |
52
+ | `scrollWindow`, `revealRow`, `buildScrollArrow` | (used by the overlays above) | Row scrolling for any container whose list outgrows its viewport |
53
+ | `editKey`, `textWindow`, `caretIndexAt` | (used by the overlays above) | Single-line text editing: caret, selection, key edits, whole-glyph scrolling |
54
+ | `buildTextFieldContent` | (used by the overlays above) | The text, selection and caret of any field, clipped to the glyphs that fit |
55
+ | | `UIKitControls` | The control layer: one owner of the pointer, wheel and keyboard for every widget above |
32
56
 
33
57
  ```ts
34
58
  import { loadDefaultFont, UIKitCrosshairOverlay } from '@niivue/uikit'
@@ -48,8 +72,8 @@ crosshair.setCrosshair({
48
72
  })
49
73
  ```
50
74
 
51
- The builders are usable on their own if you want the geometry but not UIKit's
52
- renderers:
75
+ The exported builders are usable on their own if you want the geometry but not
76
+ UIKit's renderers:
53
77
 
54
78
  ```ts
55
79
  import { buildTerminatedLine, LineTerminator } from '@niivue/uikit'
@@ -59,3 +83,676 @@ const segments = buildTerminatedLine(0, 0, 100, 40, 2, [1, 0, 0, 1], {
59
83
  end: LineTerminator.ARROW,
60
84
  })
61
85
  ```
86
+
87
+ ## Buttons
88
+
89
+ `UIKitButtonOverlay` draws push buttons into the scene and handles the pointer
90
+ for them. Each button is a rounded rect plus a centred label. The pointer gives
91
+ it a hover tint, a press that shrinks and darkens the face, and a release that
92
+ eases back; a click fires on pointer-up inside the button the pointer went down
93
+ on. `attach(nv)` wires the canvas pointer events so a click on a button is
94
+ consumed before NiiVue's own handlers see it (no stray drag or crosshair move),
95
+ and shows a pointer cursor over buttons.
96
+
97
+ ```ts
98
+ import { loadDefaultFont, UIKitButtonOverlay } from '@niivue/uikit'
99
+
100
+ // cssUnits: lay the buttons out in CSS pixels; the overlay scales by the DPR.
101
+ const buttons = new UIKitButtonOverlay(await loadDefaultFont(), { cssUnits: true })
102
+ nv.registerOverlayRenderer(buttons)
103
+ const detach = buttons.attach(nv)
104
+
105
+ buttons.addButton({
106
+ id: 'axial',
107
+ label: 'Axial',
108
+ x: 12,
109
+ y: 12, // top-left; width/height default to the label plus padding
110
+ onClick: () => { nv.sliceType = SLICE_TYPE.AXIAL },
111
+ })
112
+ buttons.addButton({
113
+ id: 'reset',
114
+ label: 'Reset',
115
+ x: 90,
116
+ y: 12,
117
+ width: 120, // a fixed size is honoured per axis
118
+ style: { fill: [0.55, 0.1, 0.12, 0.95], border: [1, 0.55, 0.55, 1], radius: 14 },
119
+ onClick: resetView,
120
+ })
121
+
122
+ buttons.updateButton('reset', { label: 'Reset (3)' })
123
+ buttons.setEnabled('axial', false)
124
+ buttons.click('reset') // from a keyboard shortcut: same animation + callback
125
+ ```
126
+
127
+ Every visual knob is in `ButtonStyle` (fill, hover, pressed and disabled fills,
128
+ border color and width, corner radius, text color and size, padding, press
129
+ shrink scale and press/release durations). Set defaults for the overlay with the
130
+ `style` option or `setDefaultStyle`, and override per button with `spec.style`.
131
+ Without `cssUnits`, all lengths are canvas (device) pixels, like every other
132
+ UIKit widget.
133
+
134
+ A host that is not a NiiVue controller can feed the overlay itself: call
135
+ `pointerDown`, `pointerMove`, `pointerUp` and `pointerCancel` with canvas-pixel
136
+ coordinates (each returns whether a button consumed the event) and pass a
137
+ `requestRedraw` option so the press animation can ask for frames.
138
+
139
+ ## Control layer
140
+
141
+ `UIKitControls` is the one place that owns the canvas pointer, wheel and
142
+ keyboard for every interactive widget, so widgets never compete for an event. Add the widget
143
+ overlays to it in draw order, register the layer as NiiVue's overlay renderer,
144
+ and `attach(nv)` to the canvas. Pointer down goes to the topmost widget that
145
+ claims it; that widget then captures every move and the up and takes keyboard
146
+ focus. The layer is single-pointer: one pointer at a time, and a second down
147
+ (another touch, or a down whose up was never seen) cancels the held press
148
+ before any widget, including the one holding it, sees the new press, so a
149
+ drag or button press restarts from the new pointer rather than sharing two.
150
+ Moves and releases from any other pointer are ignored while one holds the
151
+ press. When pointer capture is refused and the release lands outside the
152
+ canvas, the layer ends the press from the window but leaves the event to the
153
+ element under it, so a page button still gets its click.
154
+ Only the topmost widget under the pointer hovers. A widget that is modal
155
+ (a menu with its popup open) sees every event first until it closes, and the
156
+ click that dismisses it is consumed. Keys go to the modal widget, else the
157
+ focused one. A wheel turn goes to the modal widget, else to the topmost widget
158
+ under the pointer that handles wheels (an open popup scrolls with it; the page
159
+ scrolls otherwise). Events a widget consumes are stopped in the capture phase,
160
+ so a click on a control never starts a NiiVue drag or moves the crosshair. Popups are
161
+ drawn after every widget. A press anywhere else in the page, focus moving to
162
+ another element or the window losing focus deactivates the layer (held press
163
+ cancelled, popups dismissed, focus dropped), so keys reach the page again
164
+ instead of the last widget the user touched; `deactivate()` does the same from
165
+ code. The layer owns keys again on the next canvas press or `focus` call, and
166
+ a widget opened or focused from code takes them back the same way: a dialog
167
+ opened from a page button, a menu opened with `open`, or a text field given
168
+ `focus` claims the keyboard through the layer at once. An open dialog does not
169
+ keep the keys while the user is elsewhere in the page.
170
+
171
+ ```ts
172
+ import {
173
+ loadDefaultFont, UIKitButtonOverlay, UIKitControls, UIKitMenuOverlay,
174
+ UIKitSliderOverlay, UIKitToggleOverlay,
175
+ } from '@niivue/uikit'
176
+
177
+ const font = await loadDefaultFont()
178
+ const units = { cssUnits: true }
179
+ const buttons = new UIKitButtonOverlay(font, units)
180
+ const toggles = new UIKitToggleOverlay(font, units)
181
+ const sliders = new UIKitSliderOverlay(font, units)
182
+ const menus = new UIKitMenuOverlay(font, units)
183
+ const controls = new UIKitControls().add(buttons).add(toggles).add(sliders).add(menus)
184
+ nv.registerOverlayRenderer(controls)
185
+ const detach = controls.attach(nv) // call detach() to unwire the events
186
+ ```
187
+
188
+ A widget added to a layer takes the layer's redraw requester, which `attach`
189
+ defaults to `nv.drawScene`. A host that is not a NiiVue controller can feed the
190
+ layer itself through `pointerDown`, `pointerMove`, `pointerUp`, `pointerCancel`,
191
+ `wheel` and `keyDown` in canvas pixels; each returns whether a widget consumed
192
+ the event.
193
+ `UIKitButtonOverlay.attach` still works on its own: it is a one-widget layer.
194
+
195
+ ## Toggles
196
+
197
+ A toggle is a square box with a check mark and a label beside it. Pointer press
198
+ and release inside flips it; the pressed toggle takes focus (a ring around the
199
+ box) and Space or Enter flips it from the keyboard. `onChange` fires for user
200
+ changes only; `setChecked` is silent.
201
+
202
+ ```ts
203
+ toggles.addToggle({
204
+ id: 'colorbar',
205
+ label: 'Colorbar',
206
+ x: 12,
207
+ y: 52,
208
+ checked: false,
209
+ onChange: (checked) => { nv.isColorbarVisible = checked },
210
+ })
211
+ toggles.setChecked('colorbar', true) // from code, no callback
212
+ toggles.toggle('colorbar') // as the user would: fires onChange
213
+ toggles.setEnabled('colorbar', false)
214
+ ```
215
+
216
+ `ToggleStyle` holds the box size, radius, gap, fills for each state, border,
217
+ check color and width, text color and size, the label halo
218
+ (`textOutlineWidth`, default 1, and `textOutlineColor`, null for automatic
219
+ contrast), press scale and focus ring.
220
+
221
+ ## Sliders
222
+
223
+ A slider is a horizontal track with a round thumb, an optional label on the
224
+ left and value readout on the right, and optional tick marks. Pressing the track
225
+ jumps the value there and starts a drag; the thumb follows the pointer however
226
+ far it strays vertically. Values clamp to `[min, max]` and snap to `step`
227
+ when one is given; without a step the slider is continuous and the keyboard
228
+ moves by a hundredth of the range. With focus, ArrowLeft/Down and
229
+ ArrowRight/Up move one step, Shift and PageUp/PageDown move ten, Home and End
230
+ go to the ends.
231
+
232
+ ```ts
233
+ sliders.addSlider({
234
+ id: 'gamma',
235
+ label: 'Gamma',
236
+ x: 12,
237
+ y: 84,
238
+ width: 220,
239
+ min: 0.1,
240
+ max: 3,
241
+ step: 0.05,
242
+ value: 1,
243
+ showValue: true,
244
+ format: (v) => v.toFixed(2), // optional; default shows the step's decimals
245
+ tickStep: 0.5, // optional tick marks (skipped when denser than a pixel)
246
+ onInput: (v) => { nv.gamma = v }, // every change, including during a drag
247
+ onChange: (v) => save(v), // once, when a drag or key press settles a new value
248
+ })
249
+ sliders.setValue('gamma', 1) // silent
250
+ sliders.getValue('gamma')
251
+ ```
252
+
253
+ The callback contract: `onInput` fires on every value change the user makes
254
+ and `onChange` fires when the interaction ends with a value different from the
255
+ one it started with (a drag releases, a key press lands). Neither fires for
256
+ `setValue`.
257
+
258
+ The label and value sit on the scene, so they draw with a halo outline:
259
+ `textOutlineWidth` (default 1; 0 for none) and `textOutlineColor` (default
260
+ null, which picks black or white for contrast with `textColor`) in
261
+ `SliderStyle`.
262
+
263
+ ## Menus
264
+
265
+ `UIKitMenuOverlay` draws menu buttons (a label with a chevron) and, when one is
266
+ open, its popup. Items are actions, check items, radio items (exclusive within
267
+ their `group`) or separators, each with an optional right-aligned shortcut
268
+ label and `enabled` flag. The popup opens below the button and flips above it
269
+ when it would overflow the canvas; when it fits neither way it takes the
270
+ roomier side and scrolls (see "Scrolling containers" below). While open the
271
+ overlay is modal: it
272
+ highlights the row under the pointer, a click on a row activates it, a click
273
+ anywhere else closes the menu (and is not passed on), and Escape closes it.
274
+ ArrowUp/Down, Home and End move the highlight over selectable rows, Enter or
275
+ Space activates. A focused closed menu opens on Enter, Space or ArrowDown.
276
+
277
+ ```ts
278
+ menus.addMenu({
279
+ id: 'view',
280
+ label: 'View',
281
+ x: 12,
282
+ y: 12,
283
+ items: [
284
+ { id: 'axial', label: 'Axial', kind: 'radio', group: 'view', shortcut: '1' },
285
+ { id: 'mpr', label: 'Multiplanar', kind: 'radio', group: 'view', checked: true },
286
+ { id: 'sep', kind: 'separator' },
287
+ { id: 'colorbar', label: 'Colorbar', kind: 'check' },
288
+ { id: 'reset', label: 'Reset view', onSelect: resetView },
289
+ { id: 'undo', label: 'Undo', enabled: false },
290
+ ],
291
+ onSelect: (itemId, menuId) => { /* every activation */ },
292
+ onChange: (itemId, checked, menuId) => { /* check and radio state changes */ },
293
+ })
294
+ menus.setItemChecked('view', 'colorbar', true) // silent; radio items clear their group
295
+ menus.isItemChecked('view', 'axial')
296
+ menus.setItems('view', newItems)
297
+ menus.open('view'); menus.close()
298
+ ```
299
+
300
+ Activating a check item flips it; activating a radio item checks it and clears
301
+ the rest of its group. `onChange` reports each item whose state changed (the
302
+ activated one first), then the item's `onSelect` and the menu's `onSelect`
303
+ fire. `MenuStyle` holds the popup fill, border, radius and padding, row height,
304
+ highlight fill, text, mark and shortcut colors, separator and chevron sizes, and
305
+ `buttonStyle` is a `ButtonStyle` for the trigger.
306
+
307
+ ## Selects
308
+
309
+ `UIKitSelectOverlay` draws drop-down lists: a menu button whose label is the
310
+ chosen option (or a `placeholder` while nothing is chosen) over a popup listing
311
+ every option as one radio group. The button is sized to the widest option so it
312
+ keeps its width as the value changes. Opening, highlighting, choosing on release
313
+ or Enter, dismissing on an outside press or Escape and being modal while open
314
+ all behave as for a menu, a long list scrolls, and the list opens with the
315
+ current option highlighted and in view. A focused closed select also changes its value straight from the
316
+ keyboard: ArrowUp/Down (or Left/Right) step over the enabled options without
317
+ wrapping, Shift or PageUp/PageDown step by ten, Home and End jump to the ends,
318
+ and Enter, Space or Alt+ArrowDown open the list.
319
+
320
+ ```ts
321
+ const selects = new UIKitSelectOverlay(font, units)
322
+ selects.addSelect({
323
+ id: 'colormap',
324
+ x: 12,
325
+ y: 12,
326
+ options: nv.colormaps.map((name) => ({ value: name, label: name })),
327
+ value: 'gray',
328
+ onChange: (value) => nv.setVolume(0, { colormap: value }),
329
+ })
330
+ selects.setValue('colormap', 'hot') // silent; a value no option holds clears it
331
+ selects.setOptions('colormap', fewerOptions) // a value no longer offered clears
332
+ selects.getValue('colormap')
333
+ ```
334
+
335
+ `onChange(value, id)` fires once per user choice and not when the same option
336
+ is chosen again or the value is set from code. Options carry `enabled`; a
337
+ disabled option draws dimmed and is skipped by the keyboard. `buttonStyle` and
338
+ `style` are the menu's `ButtonStyle` and `MenuStyle`.
339
+
340
+ ## Scrolling containers
341
+
342
+ Any container that shows a list taller than the space it has scrolls by whole
343
+ rows through the shared model in `scroll.ts`, so a container only ever draws
344
+ rows that lie fully inside its viewport and needs no GPU scissor.
345
+ `scrollWindow(heights, viewport, first)` returns the run of rows that fits from
346
+ `first`, clamped so the list never scrolls past its end; `revealRow` finds the
347
+ least scroll that brings a row into view (a keyboard move, or opening on an
348
+ item); `WheelAccumulator` turns a trackpad's small wheel deltas into row steps;
349
+ `buildScrollArrow` draws the chevron on a strip. Menu and select popups are the
350
+ first consumers: a scrollable popup reserves a strip at each end
351
+ (`scrollStripHeight`, `scrollArrowSize`, `scrollArrowWidth`, `scrollArrowColor`
352
+ in `MenuStyle`), shows an arrow on a strip with rows beyond it, scrolls on the
353
+ wheel or a press on a strip, keeps the keyboard highlight in view, and reports
354
+ `scrollable`, `firstRow`, `endRow` and the strip boxes in its popup layout. A
355
+ widget that scrolls implements `wheel(x, y, deltaX, deltaY)` from
356
+ `UIKitInteractive`; the control layer routes the canvas wheel to it. The scroll
357
+ functions are package-private; only the `ScrollRange`, `ScrollWindow`,
358
+ `ScrollStripStyle` and `UIKitBox` types are exported.
359
+
360
+ ## Segmented controls
361
+
362
+ `UIKitSegmentedOverlay` draws a row of equal-width segments in one rounded
363
+ track with exactly one selected: a compact radio group for a mode or a view.
364
+ The pointer tints the segment under it, pressing darkens it, and releasing on
365
+ the segment the press started on selects it. The pressed control takes
366
+ keyboard focus (a ring around the track); ArrowLeft/Right (or Up/Down) then
367
+ move the selection through the enabled segments, wrapping, and Home and End
368
+ jump to the ends.
369
+
370
+ ```ts
371
+ const segmented = new UIKitSegmentedOverlay(font, units)
372
+ segmented.addSegmented({
373
+ id: 'view',
374
+ x: 12,
375
+ y: 52,
376
+ segments: [
377
+ { value: 'axial', label: 'Axial' },
378
+ { value: 'mpr', label: 'MPR' },
379
+ { value: 'render', label: 'Render', enabled: false },
380
+ ],
381
+ value: 'mpr',
382
+ onChange: (value) => { /* apply the view */ },
383
+ })
384
+ segmented.setValue('view', 'axial') // silent
385
+ segmented.select('view', 'axial') // as the user would: fires onChange
386
+ ```
387
+
388
+ `segmentWidth` fixes every segment's width instead of fitting the widest label;
389
+ `SegmentedStyle` holds the track fill and border, inset, radius, the selected,
390
+ hover and pressed faces, text colors and size, the divider and the focus ring.
391
+
392
+ ## Number inputs
393
+
394
+ `UIKitNumberInputOverlay` draws a bordered field showing a number, with a
395
+ two-button spinner at its right end and an optional label to its left. A
396
+ press on the text takes keyboard focus and selects the whole number, so typing
397
+ replaces it; a second press places the caret and dragging selects. Typed text
398
+ is checked on every keystroke (the border turns `invalidBorder` while it is
399
+ not a number) and `onInput` fires whenever it means a new valid number. Enter
400
+ commits, Escape reverts to the committed value, and losing focus commits, as
401
+ does every step: ArrowUp/Down (Shift: ten steps), PageUp/PageDown, the spinner
402
+ buttons and the wheel over a focused field. A committed value is clamped to
403
+ `[min, max]` (either end optional) and snapped to `step` (counted from `min`,
404
+ or 0), and `onChange` fires once per committed change. The field shows only
405
+ the glyphs that fit, scrolled by whole glyphs to keep the caret in view.
406
+
407
+ ```ts
408
+ const numbers = new UIKitNumberInputOverlay(font, units)
409
+ numbers.addNumberInput({
410
+ id: 'elevation',
411
+ label: 'Elevation',
412
+ x: 250,
413
+ y: 172,
414
+ width: 80, // the field; the label adds to it
415
+ min: -90,
416
+ max: 90,
417
+ step: 5,
418
+ value: 10,
419
+ format: (v) => `${v}`, // optional; default shows the step's decimals
420
+ onInput: (v) => { nv.elevation = v }, // each new valid number while typing, each step
421
+ onChange: (v) => save(v), // once per committed change
422
+ })
423
+ numbers.setValue('elevation', 0) // silent
424
+ numbers.getValue('elevation') // the committed value
425
+ numbers.getText('elevation') // what the field shows, edit in progress included
426
+ ```
427
+
428
+ `NumberInputStyle` holds the field fill, hover and disabled fills, the border
429
+ with its focus and invalid colors, text color and size, the label halo
430
+ (`textOutlineWidth`, `textOutlineColor`), padding, the default `fieldWidth`,
431
+ the spinner width, faces, dividers and chevrons, and the caret and selection
432
+ colors. `parse` on a spec replaces the default `Number` parser. The overlay
433
+ option `wheelStepPx` (default 40) is how many wheel pixels make one step.
434
+
435
+ The editing itself is the shared model in `textEdit.ts`: a `TextEditState`
436
+ (text, caret, selection anchor), `editKey` for the caret moves, Backspace,
437
+ Delete, select-all and typed characters a key means, `caretIndexAt` for the
438
+ pointer, and `textWindow`, which scrolls a field by whole glyphs through the
439
+ scroll model. `textField.ts` turns that state into the selection, text and
440
+ caret geometry of any field. Text inputs reuse both. Like the other widget
441
+ models they are package-private; only the `TextEditState` and
442
+ `TextFieldStyle` types are exported.
443
+
444
+ ## Text inputs
445
+
446
+ `UIKitTextInputOverlay` draws a bordered single-line field with an optional
447
+ label and a dimmed placeholder while it is empty. A press takes keyboard
448
+ focus and places the caret at the pointer, dragging selects, and a second
449
+ press within the double-click interval selects all. The arrows, Home and End
450
+ move the caret (Shift extends the selection, Meta or Ctrl jumps to the ends),
451
+ Backspace and Delete edit, Meta or Ctrl plus A selects all. `onInput` fires
452
+ on every keystroke that changes the text. Enter commits the text (firing
453
+ `onChange` when it differs from the committed text) and then fires `onSubmit`
454
+ whether or not it changed; Escape reverts to the committed text; losing focus
455
+ commits. Long text scrolls by whole glyphs to keep the caret in view.
456
+
457
+ Meta or Ctrl plus C, X and V copy, cut and paste the selection through a
458
+ `ClipboardBridge` (the browser's async clipboard unless the overlay's
459
+ `clipboard` option supplies one; see "Host bridges" below). A pasted text
460
+ loses its line breaks, and the spec's `accept` and `maxLength` apply to it.
461
+ A host that handles the DOM paste event itself can call `paste(text)`
462
+ instead. No DOM input backs the field, so there is no IME composition. Tab
463
+ and other keys the field does not use fall through to the host.
464
+
465
+ ```ts
466
+ const texts = new UIKitTextInputOverlay(font, units)
467
+ texts.addTextInput({
468
+ id: 'command',
469
+ label: 'niimath',
470
+ x: 12,
471
+ y: 340,
472
+ width: 220, // the field; the label adds to it
473
+ placeholder: '-sqr -add 2',
474
+ maxLength: 200,
475
+ accept: (ch) => ch !== '\t', // optional keyboard filter
476
+ onInput: (text) => preview(text), // every change
477
+ onChange: (text) => save(text), // Enter or blur, when changed
478
+ onSubmit: (text) => run(text), // Enter, always
479
+ })
480
+ texts.setValue('command', '') // silent
481
+ texts.getValue('command') // the committed text
482
+ texts.getText('command') // what the field shows, edit in progress included
483
+ ```
484
+
485
+ `TextInputStyle` holds the field fill, hover and disabled fills, the border
486
+ and its focus color, text, placeholder and disabled text colors, the label
487
+ halo (`textOutlineWidth`, `textOutlineColor`), padding, the default
488
+ `fieldWidth`, and the caret and selection colors. The overlay options take
489
+ `doubleClickMs` (default 400) and a `now` clock for it.
490
+
491
+ ## Text areas
492
+
493
+ `UIKitTextAreaOverlay` draws a bordered field of a fixed number of rows with
494
+ an optional label above it. Text soft-wraps at the field's width (after the
495
+ last space that fits, else by glyph) and scrolls by whole rows through the
496
+ shared scroll model, with a scrollbar once the rows overflow: the wheel
497
+ scrolls a hovered area, and the caret is kept in view while editing. A press
498
+ takes keyboard focus and places the caret at the pointer, dragging selects
499
+ across rows, and a second press within the double-click interval selects
500
+ all. Enter inserts a newline; the arrows move by glyph and by row (a run of
501
+ vertical moves keeps its column), Home and End move within the row, PageUp
502
+ and PageDown move by a page of rows, and Meta or Ctrl jumps to the text's
503
+ ends. Shift extends the selection; Backspace, Delete and Meta or Ctrl plus A
504
+ come from the single-line model. `onInput` fires on every keystroke that
505
+ changes the text. Meta or Ctrl plus Enter commits (firing `onChange` when the
506
+ text changed) and then fires `onSubmit`; Escape reverts to the committed
507
+ text; losing focus commits.
508
+
509
+ Copy, cut and paste work as in the text input, through the same
510
+ `ClipboardBridge`; a pasted text keeps its line breaks (CRLF becomes LF).
511
+ There is no IME composition.
512
+
513
+ ```ts
514
+ const areas = new UIKitTextAreaOverlay(font, units)
515
+ areas.addTextArea({
516
+ id: 'notes',
517
+ label: 'Notes',
518
+ x: 12,
519
+ y: 540,
520
+ width: 300,
521
+ rows: 4, // visible rows; longer text scrolls
522
+ placeholder: 'Findings...',
523
+ maxLength: 2000,
524
+ onInput: (text) => preview(text), // every change
525
+ onChange: (text) => save(text), // Meta/Ctrl+Enter or blur, when changed
526
+ onSubmit: (text) => run(text), // Meta/Ctrl+Enter, always
527
+ })
528
+ areas.setValue('notes', '') // silent
529
+ areas.getValue('notes') // the committed text
530
+ areas.getText('notes') // what the field shows, edit in progress included
531
+ areas.getFirstRow('notes') // the scroll position, in rows
532
+ ```
533
+
534
+ `TextAreaStyle` adds to the text-input knobs a `lineHeight` (rows are
535
+ `textSizePx * lineHeight`, default 1.4), the default `rows`, and the
536
+ scrollbar's `scrollbarWidth`, `scrollbarGap`, `scrollbarColor` and
537
+ `scrollbarTrackColor`. The pure model (`wrapRows`, `rowOfCaret`,
538
+ `moveCaretRows`, `textAreaKey`) is package-private; hosts that lay out
539
+ multi-line text themselves should ask for it to be exported.
540
+
541
+ ## Color controls
542
+
543
+ `UIKitColorControlOverlay` edits a color the way the legacy demos do, with
544
+ channel sliders and named palette choices rather than a browser color picker.
545
+ Each control shows a preview swatch (over a grey backdrop, so a translucent
546
+ color shows its alpha), a stack of R, G and B sliders (and A with `alpha`)
547
+ composed from a slider overlay, and a row of palette swatches that wraps to
548
+ the control's width. Dragging a slider, or pressing an arrow, Home, End or
549
+ Page key on the focused one, changes a channel; pressing a swatch sets the
550
+ whole color, and the swatch matching the current color wears a ring.
551
+ `onInput` fires on every change and `onChange` once per committed one (a
552
+ slider release, a key press, a swatch press).
553
+
554
+ ```ts
555
+ const colors = new UIKitColorControlOverlay(font, units)
556
+ colors.addColorControl({
557
+ id: 'crosshair',
558
+ label: 'Crosshair',
559
+ x: 12,
560
+ y: 380,
561
+ width: 220,
562
+ value: [1, 0, 0, 1],
563
+ alpha: false,
564
+ palette: [
565
+ { name: 'Red', color: [1, 0, 0, 1] },
566
+ { name: 'Green', color: [0, 1, 0, 1] },
567
+ { name: 'White', color: [1, 1, 1, 1] },
568
+ ],
569
+ onInput: (c) => { nv.crosshairColor = c },
570
+ onChange: (c) => save(c),
571
+ })
572
+ colors.setValue('crosshair', [0, 1, 0, 1]) // silent
573
+ colors.getValue('crosshair')
574
+ ```
575
+
576
+ `ColorControlStyle` holds the text color and size with the label halo
577
+ (`textOutlineWidth`, `textOutlineColor`), the preview size, radius, border
578
+ and backdrop, the gaps, `slider` overrides for the channel sliders, and the
579
+ swatch size, gap, radius, border and the selected and hover rings.
580
+
581
+ ## Host bridges: file pickers, downloads and the clipboard
582
+
583
+ Opening a file chooser, saving a download and reaching the clipboard are the
584
+ things a canvas widget cannot do by itself. UIKit keeps the affordance and
585
+ its state on the canvas and hands the browser-only step to a host bridge:
586
+ `FilePickerBridge` opens the chooser and resolves with the files (an empty
587
+ array on cancel; where the browser has no `cancel` event, once the window
588
+ has had focus back for a second with no pick), `DownloadBridge` saves a blob
589
+ under a name (revoking its object URL well after the click), and
590
+ `ClipboardBridge` writes text and reads it back (`read` resolves empty when
591
+ the host refuses). `createBrowserFilePicker()`, `createBrowserDownload()`
592
+ and `createBrowserClipboard()` are the browser implementations (a hidden
593
+ `<input type="file">`, a temporary anchor over an object URL,
594
+ `navigator.clipboard`); each takes the browser object as a parameter so a
595
+ test or another host can supply its own. Browsers only open a chooser or
596
+ grant the clipboard inside a user activation, so call a bridge synchronously
597
+ from a click, menu or key handler, as the widgets do. The text input and
598
+ text area create the browser clipboard on first use unless their `clipboard`
599
+ option names another bridge.
600
+
601
+ `UIKitFilePickerOverlay` is the canvas half of `<input type="file">`: a push
602
+ button ("Choose file...") composed from a button overlay beside a readout of
603
+ what was chosen ("No file chosen", one name, or "3 files"; a long name keeps
604
+ its tail behind leading dots). Pressing the button, Enter or Space on it, or
605
+ `open(id)` asks the bridge with the spec's `accept`, `multiple` and
606
+ `directory`; the picker then shows the names and fires `onPick`, or
607
+ `onCancel` when the chooser closed empty. The browser bridge adds the last
608
+ part of a multi-part extension to `accept` (`.nii.gz` brings `.gz`), since
609
+ Chrome's chooser on macOS matches only that part and would grey the files
610
+ out; other files ending in it (a `.tar.gz`) are then offered too.
611
+
612
+ ```ts
613
+ import {
614
+ acceptExtensions,
615
+ createBrowserDownload,
616
+ UIKitFilePickerOverlay,
617
+ } from '@niivue/uikit'
618
+
619
+ const pickers = new UIKitFilePickerOverlay(font, units) // pickFiles: a custom bridge
620
+ pickers.addFilePicker({
621
+ id: 'volume',
622
+ label: 'Open volume...',
623
+ x: 12,
624
+ y: 480,
625
+ width: 300,
626
+ accept: acceptExtensions(['nii', 'nii.gz', 'mgz']),
627
+ onPick: ([file]) => nv.loadVolumes([{ url: file, colormap: 'Gray' }]),
628
+ })
629
+ pickers.setFiles('volume', ['mni152.nii.gz']) // silent
630
+ pickers.getFiles('volume') // the shown names
631
+
632
+ const download = createBrowserDownload()
633
+ menus.addMenu({ id: 'file', label: 'File', x: 12, y: 12, items: [
634
+ { id: 'export', label: 'Export settings', onSelect: () =>
635
+ download(new Blob([JSON.stringify(settings)], { type: 'application/json' }), 'settings.json') },
636
+ ]})
637
+ ```
638
+
639
+ `FilePickerStyle` holds the `button` overrides, the gap, the readout colors
640
+ (chosen, empty and disabled), size and halo, and the default readout width.
641
+
642
+ ## Dialogs
643
+
644
+ `UIKitDialogOverlay` holds dialogs that open one at a time. An open dialog is
645
+ modal: a scrim covers the canvas, the panel (centered, or placed at the spec's
646
+ `x`, `y`) shows a title, a message wrapped to the panel width (`\n` forces a
647
+ line), a content box `contentHeight` tall for hosted widgets, and a row of
648
+ right-aligned action buttons. Pressing a button closes the dialog and fires
649
+ `onClose` with that button's id. Enter presses the button with the `default`
650
+ role (drawn in the accent style), Escape the one with the `cancel` role, or
651
+ closes with a null result when there is none. A press on the scrim closes with
652
+ null only when `closeOnScrim` is set; otherwise it does nothing, and every
653
+ other pointer, wheel and key event is swallowed while the dialog is open.
654
+
655
+ Hosted widgets are ordinary overlays (text inputs, number inputs, toggles,
656
+ selects, ...) added with `addChild`: while the dialog is open it routes
657
+ events among them like a control layer (a press captures and focuses, a
658
+ modal child such as an open select sees everything first) and draws them over
659
+ the panel. A focused child keeps the keys it takes: Enter submits the dialog
660
+ only from a child whose `submitsForm` says so (a text or number input's
661
+ Enter, a text area's Meta or Ctrl plus Enter), so a text area's Enter is a new
662
+ line and a select's opens its list. They must use the same units as the dialog. `onLayout` reports the
663
+ panel and content boxes in spec units whenever the panel is placed (the first
664
+ draw, a resize), which is where to position them. A dialog is not a popup: a
665
+ press elsewhere in the page only blurs its children, so a window blur never
666
+ closes a half-filled form. Keys with Meta or Ctrl held that the form does not
667
+ use (reload, find, zoom) pass through to the browser. The dialog owns what it hosts: `removeDialog` and
668
+ `destroy` release the children's GPU resources along with the action
669
+ buttons, so `controls.destroy()` tears a whole form down; `removeChild`
670
+ first to keep a child alive.
671
+
672
+ ```ts
673
+ const dialogs = new UIKitDialogOverlay(font, units)
674
+ const fields = new UIKitTextInputOverlay(font, units)
675
+ controls.add(dialogs) // the dialog hosts `fields`; do not add them to the layer
676
+ dialogs.addDialog({
677
+ id: 'rename',
678
+ title: 'Rename volume',
679
+ message: 'The name shows in the layer list and in exports.',
680
+ contentHeight: 36,
681
+ buttons: [
682
+ { id: 'cancel', label: 'Cancel', role: 'cancel' },
683
+ { id: 'ok', label: 'Rename', role: 'default' },
684
+ ],
685
+ onLayout: ({ content }) => {
686
+ fields.updateTextInput('name', { x: content.x, y: content.y + 4 })
687
+ },
688
+ onClose: (result) => {
689
+ if (result === 'ok') rename(fields.getValue('name'))
690
+ },
691
+ })
692
+ dialogs.addChild('rename', fields)
693
+ fields.addTextInput({ id: 'name', x: 0, y: 0, width: 300, value: 'mni152' })
694
+ dialogs.open('rename')
695
+ dialogs.click('ok') // as the user would: closes and fires onClose('ok')
696
+ dialogs.close('cancel') // from code, with any result
697
+ ```
698
+
699
+ `DialogStyle` holds the scrim color, the panel fill, border and radius, its
700
+ default `width`, `padding` and `gap`, the title and message colors and sizes,
701
+ the message `lineHeight`, the `buttonGap`, and `button` and `defaultButton`
702
+ overrides of the button style.
703
+
704
+ ## Demo pages
705
+
706
+ `bunx nx dev uikit` serves the package's own demo pages with Vite; the dev
707
+ server also mounts `@niivue/dev-images`, so the pages can load real volumes
708
+ from `/volumes/...`. Each page's nav links to the others.
709
+
710
+ | Page | Shows |
711
+ | --- | --- |
712
+ | `index.html` | The ruler on a blank pane |
713
+ | `volume.html` | The ruler over a NiiVue volume, measuring in millimetres from the slice pick |
714
+ | `slide.html` | The ruler over a Deep Zoom slide in the 3D render tile, measuring in screen pixels |
715
+ | `slide-wsi.html` | The ruler on the standalone WSI viewer with a DICOM whole-slide image |
716
+ | `buttons.html` | `UIKitButtonOverlay` on its own: view buttons, a toggling label and a styled reset |
717
+ | `controls.html` | Every widget on one `UIKitControls` layer over a volume: a menu bar, segmented control, select, toggles, sliders, number and text inputs, a text area, a color control, a file picker and a dialog |
718
+
719
+ ## Conventions for contributors
720
+
721
+ - **A widget is a model file plus an overlay file.** `foo.ts` holds the pure
722
+ model: the `FooSpec` and `FooStyle` interfaces, `DEFAULT_FOO_STYLE`,
723
+ `resolveFooStyle`, `layoutFoo` (spec to boxes), `scaleFoo` (spec and style
724
+ into device pixels for `cssUnits`), `fooContains` (hit test) and `buildFoo`
725
+ (layout and state to line, text and rect draw data). It imports no GPU code
726
+ and is tested in `foo.test.ts` without one. `fooOverlay.ts` holds
727
+ `UIKitFooOverlay`: it keeps the specs and interaction state, implements
728
+ `UIKitInteractive` (`hitTest`, the pointer methods, `keyDown`, `wheel` where
729
+ it scrolls, `blur`, `bindLayer`) and draws through the lifecycle hook.
730
+ `fooOverlay.test.ts` drives it through those methods with a stub renderer.
731
+ (The select is the exception: `select.ts` is helper functions that turn a
732
+ `SelectSpec` into a menu spec, and its overlay wraps a menu overlay.)
733
+ - **Overlays share the primitives.** Draw through `UIKitRectOverlay`,
734
+ `UIKitLineOverlay` and `UIKitTextOverlay` (and the `render/` renderers under
735
+ them); a composite widget composes another overlay (the file picker holds a
736
+ button overlay, the color control a slider overlay, the select a menu
737
+ overlay) rather than redrawing it.
738
+ - **Shared behaviour lives in shared models.** Row scrolling is `scroll.ts`,
739
+ single-line editing `textEdit.ts` and `textField.ts`, multi-line editing
740
+ `textArea.ts`, text wrapping `wrapText` in `dialog.ts`. Extend those before
741
+ adding a widget-local copy.
742
+ - **Browser-only steps are host bridges in `host.ts`.** A widget never touches
743
+ the DOM, the clipboard or a download itself: it calls a bridge type
744
+ (`FilePickerBridge`, `DownloadBridge`, `ClipboardBridge`) that the overlay
745
+ option names and `createBrowser*` implements, so tests and non-browser hosts
746
+ supply their own.
747
+ - **Callbacks follow one contract.** `onInput` fires on every user change,
748
+ `onChange` once per committed change, `onSubmit` on an explicit submit;
749
+ setters from code (`setValue`, `setChecked`, `setItemChecked`) are silent.
750
+ Every callback receives the widget id last.
751
+ - **Units.** Specs and styles are canvas pixels unless the overlay was made
752
+ with `cssUnits`, in which case the overlay scales them by the device pixel
753
+ ratio at draw time; widgets hosted by a dialog must use the dialog's units.
754
+ - **Export the overlay, its options type, the spec and style types and the
755
+ default style from `src/index.ts`** (the one permitted barrel). The pure
756
+ model (layout, build, hit-test and state helpers) stays package-private:
757
+ it is exercised by the widget's own tests and is not an API. Add the widget
758
+ to the table at the top of this file and to `controls.html`.