@niivue/uikit 1.0.0-rc.4 → 1.0.0-rc.41

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