@react-x11/components 0.2.1 → 0.4.0

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 (137) hide show
  1. package/README.md +6 -0
  2. package/dist/index.d.ts +4 -0
  3. package/dist/index.d.ts.map +1 -1
  4. package/dist/index.js +5 -0
  5. package/dist/index.js.map +1 -1
  6. package/dist/internal/heights.d.ts +26 -1
  7. package/dist/internal/heights.d.ts.map +1 -1
  8. package/dist/internal/heights.js +57 -6
  9. package/dist/internal/heights.js.map +1 -1
  10. package/dist/internal/scroll.d.ts +11 -0
  11. package/dist/internal/scroll.d.ts.map +1 -1
  12. package/dist/internal/scroll.js +43 -3
  13. package/dist/internal/scroll.js.map +1 -1
  14. package/dist/internal/timers.d.ts +8 -0
  15. package/dist/internal/timers.d.ts.map +1 -1
  16. package/dist/internal/timers.js +10 -0
  17. package/dist/internal/timers.js.map +1 -1
  18. package/dist/internal/window.d.ts +161 -0
  19. package/dist/internal/window.d.ts.map +1 -0
  20. package/dist/internal/window.js +417 -0
  21. package/dist/internal/window.js.map +1 -0
  22. package/dist/qml/containers.d.ts +3 -0
  23. package/dist/qml/containers.d.ts.map +1 -0
  24. package/dist/qml/containers.js +331 -0
  25. package/dist/qml/containers.js.map +1 -0
  26. package/dist/qml/controls.d.ts +12 -0
  27. package/dist/qml/controls.d.ts.map +1 -0
  28. package/dist/qml/controls.js +21 -0
  29. package/dist/qml/controls.js.map +1 -0
  30. package/dist/qml/globals.d.ts +6 -0
  31. package/dist/qml/globals.d.ts.map +1 -0
  32. package/dist/qml/globals.js +25 -0
  33. package/dist/qml/globals.js.map +1 -0
  34. package/dist/qml/index.d.ts +15 -0
  35. package/dist/qml/index.d.ts.map +1 -0
  36. package/dist/qml/index.js +25 -0
  37. package/dist/qml/index.js.map +1 -0
  38. package/dist/qml/interaction.d.ts +12 -0
  39. package/dist/qml/interaction.d.ts.map +1 -0
  40. package/dist/qml/interaction.js +246 -0
  41. package/dist/qml/interaction.js.map +1 -0
  42. package/dist/qml/ir.d.ts +106 -0
  43. package/dist/qml/ir.d.ts.map +1 -0
  44. package/dist/qml/ir.js +7 -0
  45. package/dist/qml/ir.js.map +1 -0
  46. package/dist/qml/layouts.d.ts +3 -0
  47. package/dist/qml/layouts.d.ts.map +1 -0
  48. package/dist/qml/layouts.js +122 -0
  49. package/dist/qml/layouts.js.map +1 -0
  50. package/dist/qml/models.d.ts +14 -0
  51. package/dist/qml/models.d.ts.map +1 -0
  52. package/dist/qml/models.js +80 -0
  53. package/dist/qml/models.js.map +1 -0
  54. package/dist/qml/objects.d.ts +228 -0
  55. package/dist/qml/objects.d.ts.map +1 -0
  56. package/dist/qml/objects.js +1103 -0
  57. package/dist/qml/objects.js.map +1 -0
  58. package/dist/qml/parse.d.ts +10 -0
  59. package/dist/qml/parse.d.ts.map +1 -0
  60. package/dist/qml/parse.js +607 -0
  61. package/dist/qml/parse.js.map +1 -0
  62. package/dist/qml/qtquick.d.ts +7 -0
  63. package/dist/qml/qtquick.d.ts.map +1 -0
  64. package/dist/qml/qtquick.js +347 -0
  65. package/dist/qml/qtquick.js.map +1 -0
  66. package/dist/qml/react.d.ts +83 -0
  67. package/dist/qml/react.d.ts.map +1 -0
  68. package/dist/qml/react.js +323 -0
  69. package/dist/qml/react.js.map +1 -0
  70. package/dist/qml/resolver.d.ts +38 -0
  71. package/dist/qml/resolver.d.ts.map +1 -0
  72. package/dist/qml/resolver.js +64 -0
  73. package/dist/qml/resolver.js.map +1 -0
  74. package/dist/qml/slots.d.ts +65 -0
  75. package/dist/qml/slots.d.ts.map +1 -0
  76. package/dist/qml/slots.js +230 -0
  77. package/dist/qml/slots.js.map +1 -0
  78. package/dist/qml/states.d.ts +5 -0
  79. package/dist/qml/states.d.ts.map +1 -0
  80. package/dist/qml/states.js +276 -0
  81. package/dist/qml/states.js.map +1 -0
  82. package/dist/qml/view-utils.d.ts +42 -0
  83. package/dist/qml/view-utils.d.ts.map +1 -0
  84. package/dist/qml/view-utils.js +121 -0
  85. package/dist/qml/view-utils.js.map +1 -0
  86. package/dist/table/index.d.ts +73 -2
  87. package/dist/table/index.d.ts.map +1 -1
  88. package/dist/table/index.js +405 -161
  89. package/dist/table/index.js.map +1 -1
  90. package/dist/tabs/hx.d.ts +18 -0
  91. package/dist/tabs/hx.d.ts.map +1 -0
  92. package/dist/tabs/hx.js +10 -0
  93. package/dist/tabs/hx.js.map +1 -0
  94. package/dist/tabs/index.d.ts +143 -0
  95. package/dist/tabs/index.d.ts.map +1 -0
  96. package/dist/tabs/index.js +602 -0
  97. package/dist/tabs/index.js.map +1 -0
  98. package/dist/terminal/vt/index.d.ts +1 -1
  99. package/dist/terminal/vt/index.d.ts.map +1 -1
  100. package/dist/terminal/vt/index.js +3 -3
  101. package/dist/terminal/vt/index.js.map +1 -1
  102. package/dist/terminal/vt/pty.d.ts +16 -0
  103. package/dist/terminal/vt/pty.d.ts.map +1 -1
  104. package/dist/terminal/vt/pty.js +186 -0
  105. package/dist/terminal/vt/pty.js.map +1 -1
  106. package/dist/tree/index.d.ts +65 -5
  107. package/dist/tree/index.d.ts.map +1 -1
  108. package/dist/tree/index.js +446 -189
  109. package/dist/tree/index.js.map +1 -1
  110. package/package.json +11 -1
  111. package/src/index.ts +44 -0
  112. package/src/internal/heights.ts +57 -6
  113. package/src/internal/scroll.ts +59 -3
  114. package/src/internal/timers.ts +13 -0
  115. package/src/internal/window.ts +570 -0
  116. package/src/qml/containers.tsx +380 -0
  117. package/src/qml/controls.tsx +47 -0
  118. package/src/qml/globals.ts +33 -0
  119. package/src/qml/index.ts +67 -0
  120. package/src/qml/interaction.tsx +317 -0
  121. package/src/qml/ir.ts +80 -0
  122. package/src/qml/layouts.tsx +141 -0
  123. package/src/qml/models.ts +101 -0
  124. package/src/qml/objects.ts +1431 -0
  125. package/src/qml/parse.ts +624 -0
  126. package/src/qml/qtquick.tsx +413 -0
  127. package/src/qml/react.tsx +454 -0
  128. package/src/qml/resolver.ts +99 -0
  129. package/src/qml/slots.ts +249 -0
  130. package/src/qml/states.ts +318 -0
  131. package/src/qml/view-utils.ts +140 -0
  132. package/src/table/index.ts +599 -178
  133. package/src/tabs/hx.ts +31 -0
  134. package/src/tabs/index.ts +850 -0
  135. package/src/terminal/vt/index.ts +4 -2
  136. package/src/terminal/vt/pty.ts +243 -0
  137. package/src/tree/index.ts +687 -260
@@ -36,8 +36,9 @@ import { hx } from './hx.js';
36
36
  // Shared with <Table> — internal, deliberately not a shared *module*; the
37
37
  // header of src/internal/heights.ts says why.
38
38
  import { RowHeights } from '../internal/heights.js';
39
- import { afterLayout, cancelAfterLayout } from '../internal/timers.js';
39
+ import { afterLayout, cancelAfterLayout, cancelLater, later, } from '../internal/timers.js';
40
40
  import { useReveal } from '../internal/scroll.js';
41
+ import { BURST_BUDGET, DEFAULT_OVERSCAN, DEFAULT_PREFETCH, SCROLL_HINT_DELAY_MS, SKELETON_THRESHOLD, SETTLE_BUDGET, useVirtualWindow, } from '../internal/window.js';
41
42
  import { typeAheadChar, useTypeAhead } from './internal.js';
42
43
  import { branchEdges, findItem, groupRows, isGroup, resolveAccessors, visibleRows, } from './rows.js';
43
44
  export { branchEdges, findItem, resolveAccessors, visibleRows, } from './rows.js';
@@ -50,16 +51,6 @@ const TWISTY = 12;
50
51
  * is half of that, so `size` for one reads as its width. */
51
52
  const TWISTY_GLYPH = 10;
52
53
  const ROW_HEIGHT = 22;
53
- /** Rows kept either side of the viewport, so a fast scroll does not show a
54
- * gap before the next frame catches up. */
55
- const OVERSCAN = 6;
56
- /**
57
- * What to build before the viewport has been measured. `onViewport` cannot
58
- * arrive until layout has run, which is a frame after the first commit, so
59
- * there is always one render that has to guess — and guessing "all of them"
60
- * puts a hundred thousand rows in the tree for a frame.
61
- */
62
- const ASSUMED_ROWS = 40;
63
54
  /**
64
55
  * Where `virtual="auto"` starts virtualizing.
65
56
  *
@@ -110,6 +101,39 @@ const s = createStyles({
110
101
  },
111
102
  subtree: { flexShrink: 0 },
112
103
  spacer: { flexShrink: 0 },
104
+ /** The bar inside a skeleton row — a line of "text" with no text, so a
105
+ * band of placeholders reads as rows arriving rather than a void. */
106
+ skeletonBar: {
107
+ height: 8,
108
+ borderRadius: 4,
109
+ alignSelf: 'center',
110
+ flexShrink: 0,
111
+ },
112
+ /** The box the scroll pane and the fast-scroll pill share — it exists so
113
+ * the pill can float *outside* the pane, where a scroll cannot move it. */
114
+ outer: { flexGrow: 1, minHeight: 0 },
115
+ /** The lane the fast-scroll pill floats in: absolute against the outer
116
+ * box so the pane scrolls under it, full-width so the pill centres
117
+ * itself, and transparent to the pointer so the rows beneath stay
118
+ * clickable. */
119
+ scrollHintLane: {
120
+ position: 'absolute',
121
+ left: 0,
122
+ right: 0,
123
+ bottom: 12,
124
+ flexDirection: 'row',
125
+ justifyContent: 'center',
126
+ pointerEvents: 'none',
127
+ },
128
+ scrollHint: {
129
+ paddingStart: 10,
130
+ paddingEnd: 10,
131
+ paddingTop: 5,
132
+ paddingBottom: 5,
133
+ borderRadius: 12,
134
+ flexDirection: 'row',
135
+ alignItems: 'center',
136
+ },
113
137
  });
114
138
  /** A string or a number becomes a `<text>`; anything else is already a
115
139
  * node. */
@@ -118,6 +142,144 @@ function labelNode(label, style) {
118
142
  ? hx('text', { key: 'label', style }, String(label))
119
143
  : label;
120
144
  }
145
+ function TreeRowView(props) {
146
+ const { row, isSelected, indent, rowHeight, rtl, theme, renderToggle, renderGuide, renderLabel, renderContent, rowStyle, guideStyle, toggleStyle, labelStyle, getLabel, onToggle, onGo, onOpen, register, } = props;
147
+ const color = row.disabled
148
+ ? theme.textMuted
149
+ : isSelected
150
+ ? theme.hoverText
151
+ : theme.text;
152
+ const state = {
153
+ ...row,
154
+ selected: isSelected,
155
+ color,
156
+ toggle: (open) => onToggle(row.id, row.item, open),
157
+ select: () => onGo(row),
158
+ };
159
+ const content = [];
160
+ // The indent. With no guide seam it is one padding value rather than
161
+ // `depth` empty boxes — a tree ten deep would otherwise build ten nodes
162
+ // per row to draw nothing.
163
+ if (renderGuide && row.depth > 0) {
164
+ const edges = branchEdges(row);
165
+ for (let level = 0; level < row.depth; level++) {
166
+ const guide = {
167
+ row: state,
168
+ level,
169
+ continues: edges[level],
170
+ own: level === row.depth - 1,
171
+ width: indent,
172
+ height: rowHeight,
173
+ };
174
+ content.push(hx('box', {
175
+ key: `guide${level}`,
176
+ style: [
177
+ s.guide,
178
+ { width: indent },
179
+ typeof guideStyle === 'function' ? guideStyle(guide) : guideStyle,
180
+ ],
181
+ }, renderGuide(guide)));
182
+ }
183
+ }
184
+ const toggleState = { ...state, size: TWISTY_GLYPH };
185
+ content.push(hx('box', {
186
+ key: 'toggle',
187
+ style: [s.twisty, toggleStyle],
188
+ // The twisty is its own hit target: clicking it opens the branch
189
+ // without moving the selection, the way a file browser lets you
190
+ // peek inside a folder you have not chosen.
191
+ onClick: row.branch
192
+ ? (ev) => {
193
+ ev.stopPropagation();
194
+ onToggle(row.id, row.item);
195
+ }
196
+ : undefined,
197
+ }, renderToggle
198
+ ? renderToggle(toggleState)
199
+ : row.branch
200
+ ? React.createElement(Icon, {
201
+ name: row.open
202
+ ? 'chevronDown'
203
+ : rtl
204
+ ? 'chevronLeft'
205
+ : 'chevronRight',
206
+ size: TWISTY_GLYPH,
207
+ // dimmer than the label on a resting row, and the row's own
208
+ // ink once it is selected
209
+ style: isSelected ? undefined : { color: theme.textMuted },
210
+ })
211
+ : null));
212
+ content.push(renderLabel
213
+ ? // Keyed here rather than by the app, for the reason `renderSubtree`
214
+ // is: the label sits in an array beside the guides and the twisty,
215
+ // and "add a key to the box you return" is not something a render
216
+ // prop should have to know.
217
+ React.createElement(React.Fragment, { key: 'label' }, renderLabel(state))
218
+ : labelNode(getLabel(row.item), [s.label, labelStyle]));
219
+ return hx('box', {
220
+ role: 'treeitem',
221
+ 'aria-level': row.depth + 1,
222
+ 'aria-selected': isSelected,
223
+ 'aria-expanded': row.branch ? row.open : undefined,
224
+ 'aria-posinset': row.posInSet,
225
+ 'aria-setsize': row.setSize,
226
+ // `disabled` rather than `aria-disabled`: on a react-x11 node it is
227
+ // the real thing — it clears the AT-SPI ENABLED/SENSITIVE states and
228
+ // selects the `:disabled` style block — and there is no aria spelling
229
+ // of it to write instead.
230
+ disabled: row.disabled || undefined,
231
+ // The index the row was drawn at travels with the node, so measuring
232
+ // does not have to search a hundred thousand rows for where it is.
233
+ // It can go stale — the rows may move before the tick that measures —
234
+ // and both this and the height index check it rather than trust it.
235
+ ref: (node) => {
236
+ register(row.id, row.index, node);
237
+ },
238
+ onClick: (ev) => {
239
+ if (row.disabled)
240
+ return;
241
+ onGo(row);
242
+ // Select on the first click, open on the second — the gesture every
243
+ // file list has. `detail` is the click count the renderer already
244
+ // counts for text selection.
245
+ if (ev.detail === 2)
246
+ onOpen(row);
247
+ },
248
+ style: [
249
+ s.row,
250
+ // A floor, not a height. The row grows to whatever its content
251
+ // needs — a wrapped label, two lines, a thumbnail — and the height
252
+ // index reads back what it actually became.
253
+ { minHeight: rowHeight },
254
+ // The indent is what says "inside", so it is measured from the edge
255
+ // the row's label begins at.
256
+ { paddingStart: renderGuide ? 4 : 4 + row.depth * indent },
257
+ {
258
+ backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
259
+ // The row's ink, said once: `color` inherits, so the label takes
260
+ // it without being handed it.
261
+ color,
262
+ },
263
+ !row.disabled && {
264
+ ':hover': {
265
+ backgroundColor: isSelected
266
+ ? theme.hoverBackground
267
+ : theme.surfaceHover,
268
+ },
269
+ // The selection only moves on the release, and `:active` marks
270
+ // the whole press chain, so a press on the label or the twisty
271
+ // still darkens the row it is in.
272
+ ':active': {
273
+ backgroundColor: isSelected
274
+ ? theme.accentActive
275
+ : theme.surfaceActive,
276
+ },
277
+ },
278
+ typeof rowStyle === 'function' ? rowStyle(state) : rowStyle,
279
+ ],
280
+ }, renderContent ? renderContent(state, content) : content);
281
+ }
282
+ const MemoTreeRow = React.memo(TreeRowView);
121
283
  /**
122
284
  * `<Tree items />` — a disclosure tree.
123
285
  *
@@ -151,17 +313,17 @@ function labelNode(label, style) {
151
313
  * such policy is expressible on top of what is here: hold the set yourself,
152
314
  * pass `selected` for the cursor, and paint the rest from `styles.row`.
153
315
  */
154
- export function Tree({ items = [], expanded, defaultExpanded, onExpandedChange, selected, defaultSelected, onSelect, onActivate, indent = INDENT, rowHeight = ROW_HEIGHT, estimatedRowHeight, virtual = 'auto', overscan = OVERSCAN, layout = 'flat', renderToggle, renderGuide, renderLabel, renderContent, renderSubtree, styles, style, ref,
316
+ export function Tree({ items = [], expanded, defaultExpanded, onExpandedChange, selected, defaultSelected, onSelect, onActivate, indent = INDENT, rowHeight = ROW_HEIGHT, estimatedRowHeight, virtual = 'auto', overscan = DEFAULT_OVERSCAN, prefetch = DEFAULT_PREFETCH, layout = 'flat', renderToggle, renderGuide, renderLabel, renderContent, renderSubtree, renderScrollHint, scrollHintDelay = SCROLL_HINT_DELAY_MS, catchup, styles, style, ref,
155
317
  // the accessors, pulled out so the rest can be spread onto the box
156
318
  getId, getLabel, getText, getChildren, isBranch, isDisabled,
157
319
  // ours to chain rather than to hand over: virtualization is measured
158
320
  // through both of these
159
321
  onScroll, onViewport, ...boxProps }) {
322
+ globalThis.__renders = (globalThis.__renders ?? 0) + 1;
160
323
  const theme = useTheme();
161
324
  const rtl = useDirection() === 'rtl';
162
325
  const [ownExpanded, setOwnExpanded] = useState(() => new Set(defaultExpanded));
163
326
  const [ownSelected, setOwnSelected] = useState(defaultSelected ?? null);
164
- const [view, setView] = useState({ top: 0, height: 0 });
165
327
  // Bumped by a measurement pass that found a row taller or shorter than the
166
328
  // index believed. It is the only reason the component re-renders for a
167
329
  // measurement, and a pass that finds nothing new does not bump it, which is
@@ -200,8 +362,6 @@ onScroll, onViewport, ...boxProps }) {
200
362
  rowsRef.current = rows;
201
363
  const itemsRef = useRef(items);
202
364
  itemsRef.current = items;
203
- const viewRef = useRef(view);
204
- viewRef.current = view;
205
365
  const virtualizing = layout === 'flat' &&
206
366
  (virtual === true ||
207
367
  (virtual === 'auto' && rows.length > VIRTUAL_THRESHOLD));
@@ -210,28 +370,27 @@ onScroll, onViewport, ...boxProps }) {
210
370
  // on every render that did not change the tree.
211
371
  const index = heights;
212
372
  index.sync(rows, estimate);
213
- // The slice worth building: what is on screen, plus a little either side.
214
- // Which rows those are is a question for the height index now — with rows
215
- // of different heights there is no division that answers it.
216
- const first = virtualizing
217
- ? Math.max(0, index.indexAt(view.top) - overscan)
218
- : 0;
219
- let last = rows.length;
220
- if (virtualizing) {
221
- if (view.height > 0) {
222
- last = Math.min(rows.length, index.indexAt(view.top + view.height) + 1 + overscan);
223
- }
224
- else {
225
- // Before the first layout there is no viewport to measure against, and
226
- // guessing "all of them" would put a hundred thousand rows in the tree
227
- // for a frame.
228
- last = Math.min(rows.length, first + ASSUMED_ROWS);
229
- }
230
- }
231
- /** Where the slice starts, and how much of the list is below it — the two
232
- * spacers that keep the scrollbar measuring the whole tree. */
233
- const above = virtualizing ? index.offsetAt(first) : 0;
234
- const below = virtualizing ? index.total() - index.offsetAt(last) : 0;
373
+ /** The viewport, and the slice worth building from it the machinery
374
+ * shared with `<Table>` (`../internal/window.ts`). */
375
+ const win = useVirtualWindow({
376
+ box: scroller,
377
+ heights,
378
+ rows,
379
+ // tree rows are always measured, so the idle band above the viewport
380
+ // only re-builds territory already visited — see `exact` on the inputs
381
+ exact: false,
382
+ virtualizing,
383
+ overscan,
384
+ prefetch,
385
+ threshold: catchup?.threshold ?? SKELETON_THRESHOLD,
386
+ burstBudget: catchup?.burst ?? BURST_BUDGET,
387
+ settleBudget: catchup?.settle ?? SETTLE_BUDGET,
388
+ });
389
+ const { view, viewRef } = win;
390
+ /** Whether the fast-scroll pill is up — kept across renders so it does not
391
+ * flicker through a catch-up, only appearing and disappearing once. */
392
+ const hintShown = useRef(false);
393
+ const { first, last, above, below } = win.slice;
235
394
  const setExpandedSet = useCallback((next, change) => {
236
395
  openRef.current = next;
237
396
  if (expanded === undefined)
@@ -269,22 +428,9 @@ onScroll, onViewport, ...boxProps }) {
269
428
  // eslint-disable-next-line react-hooks/exhaustive-deps -- `reveal` is a
270
429
  // stable handle
271
430
  }, [reveal]);
272
- /**
273
- * Re-read the offset the pane is *actually* at.
274
- *
275
- * It moves silently — a queued reveal resolves during layout, and an offset
276
- * the content outgrew or outshrank is re-clamped there — and a slice built
277
- * from the offset before those is drawn where the viewport is not: a blank
278
- * band where the rows should be, until a scroll of your own re-syncs it by
279
- * accident.
280
- */
281
- const syncScroll = useCallback(() => {
282
- const box = scroller.current;
283
- if (!box || !virtualizing)
284
- return;
285
- const y = box.scrollY;
286
- setView((prev) => (prev.top === y ? prev : { ...prev, top: y }));
287
- }, [virtualizing]);
431
+ /** Re-read the offset the pane is *actually* at — the window's `sync`; see
432
+ * `../internal/window.ts` for why the pane moves silently. */
433
+ const syncScroll = win.sync;
288
434
  /**
289
435
  * Read back what the rows on screen actually laid out at.
290
436
  *
@@ -324,12 +470,36 @@ onScroll, onViewport, ...boxProps }) {
324
470
  }
325
471
  if (!changed)
326
472
  return false;
327
- if (shift !== 0 && box) {
328
- reveal.scrollTo(box.scrollY + shift);
329
- }
473
+ // A debt, not a one-shot: the pane clamps against the last layout's
474
+ // content height, so a shift from rows measured above the viewport can
475
+ // land short until the layout that admits the growth has run.
476
+ reveal.nudge(shift);
330
477
  setMeasured((n) => n + 1);
331
478
  return true;
332
479
  }, [virtualizing]);
480
+ /**
481
+ * Let the estimate learn from the rows that have been measured — the
482
+ * scrollbar of a measured tree starts as a guess times the row count, and
483
+ * the measured mean is a far better guess for the rows not yet seen. Idle
484
+ * only: every unmeasured offset moves when it applies, and the anchor
485
+ * arithmetic keeping the screen still is `measureRows`'s.
486
+ */
487
+ const adaptEstimate = useCallback(() => {
488
+ if (!virtualizing)
489
+ return false;
490
+ const box = scroller.current;
491
+ if (!box)
492
+ return false;
493
+ const anchor = heights.indexAt(box.scrollY);
494
+ const before = heights.offsetAt(anchor);
495
+ if (!heights.adapt())
496
+ return false;
497
+ reveal.nudge(heights.offsetAt(anchor) - before);
498
+ setMeasured((n) => n + 1);
499
+ return true;
500
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `heights` and
501
+ // `reveal` are stable instances
502
+ }, [virtualizing]);
333
503
  /**
334
504
  * The one tick after layout, and everything that can only be known there:
335
505
  * what the rows measured, whether an owed scroll can go further now that
@@ -337,17 +507,46 @@ onScroll, onViewport, ...boxProps }) {
337
507
  * ended up. In that order — each step can move the offset the next one
338
508
  * reads.
339
509
  */
510
+ /** Whether some drawn row has no size yet — a commit can land between
511
+ * frame flushes, and a measure pass over it reads zeros. */
512
+ const rowsPendingLayout = useCallback(() => {
513
+ const rows = rowsRef.current;
514
+ for (const [id, { node, at }] of rowNodes.current) {
515
+ if (rows[at]?.id === id && !(node.abs.height > 0))
516
+ return true;
517
+ }
518
+ return false;
519
+ }, []);
340
520
  useEffect(() => {
341
521
  if (!virtualizing)
342
522
  return undefined;
343
- const id = afterLayout(() => {
523
+ let look = null;
524
+ let tries = 0;
525
+ const pass = () => {
526
+ globalThis.__ticks = (globalThis.__ticks ?? 0) + 1;
344
527
  // `measureRows` first, and its answer handed on: a pass that moved the
345
528
  // heights has not settled anything, and an owed scroll judged against
346
- // the layout it is about to invalidate is not owed any less.
347
- reveal.retry(measureRows());
529
+ // the layout it is about to invalidate is not owed any less. During a
530
+ // flick nothing is measured at all — every correction at that speed is
531
+ // invalidated by the next event — and the settle tick that follows any
532
+ // burst is where the deferred passes catch up.
533
+ const moved = win.fast() ? false : measureRows();
534
+ const adapted = !win.scrolling() && adaptEstimate();
535
+ reveal.retry(moved || adapted);
348
536
  syncScroll();
349
- });
350
- return () => cancelAfterLayout(id);
537
+ // A commit can land between frame flushes: its rows report zero size
538
+ // until the flush, this tick has already run, and nothing else would
539
+ // come back for them — a window that just finished growing renders
540
+ // nothing further, and the missed measurements would stand for good.
541
+ // Look again, briefly, while any drawn row is still unsized.
542
+ if (rowsPendingLayout() && tries++ < 8)
543
+ look = later(pass, 16);
544
+ };
545
+ const id = afterLayout(pass);
546
+ return () => {
547
+ cancelAfterLayout(id);
548
+ cancelLater(look);
549
+ };
351
550
  });
352
551
  const goTo = useCallback((row) => {
353
552
  if (!row)
@@ -509,7 +708,91 @@ onScroll, onViewport, ...boxProps }) {
509
708
  // --- rendering -----------------------------------------------------------
510
709
  const rowStyleProp = styles?.row;
511
710
  const guideStyleProp = styles?.guide;
711
+ // The row component's stable half — `MemoTreeRow` bails out of a
712
+ // re-render only if every prop kept its identity, and this one would
713
+ // otherwise be rebuilt per row per render.
714
+ const registerRow = useCallback((id, at, node) => {
715
+ if (node)
716
+ rowNodes.current.set(id, { node, at });
717
+ else
718
+ rowNodes.current.delete(id);
719
+ }, []);
720
+ /**
721
+ * The row *elements*, reused by identity while nothing they depend on has
722
+ * changed. The memo already skips re-rendering an unchanged row, but the
723
+ * skip still costs a `createElement` and a props compare per row per
724
+ * notch — the burst profile put bare `createElement` at a tenth of a
725
+ * flick's CPU. Handing React the identical element object instead takes
726
+ * the cheapest path it has: the fiber is reused with no compare at all.
727
+ */
728
+ const rowElems = useRef(new Map());
729
+ const rowElemDeps = useRef([]);
730
+ {
731
+ const deps = [
732
+ indent,
733
+ rowHeight,
734
+ rtl,
735
+ theme,
736
+ renderToggle,
737
+ renderGuide,
738
+ renderLabel,
739
+ renderContent,
740
+ rowStyleProp,
741
+ guideStyleProp,
742
+ styles?.toggle,
743
+ styles?.label,
744
+ accessors,
745
+ toggleId,
746
+ goTo,
747
+ activate,
748
+ registerRow,
749
+ ];
750
+ const prev = rowElemDeps.current;
751
+ if (prev.length !== deps.length || deps.some((d, at) => d !== prev[at])) {
752
+ rowElems.current.clear();
753
+ rowElemDeps.current = deps;
754
+ }
755
+ }
512
756
  const renderOneRow = (row) => {
757
+ const isSelected = row.id === current;
758
+ const cached = rowElems.current.get(row.id);
759
+ if (cached && cached.row === row && cached.selected === isSelected) {
760
+ return cached.el;
761
+ }
762
+ const el = React.createElement(MemoTreeRow, {
763
+ key: String(row.id),
764
+ row,
765
+ isSelected,
766
+ indent,
767
+ rowHeight,
768
+ rtl,
769
+ theme,
770
+ renderToggle,
771
+ renderGuide,
772
+ renderLabel,
773
+ renderContent,
774
+ rowStyle: rowStyleProp,
775
+ guideStyle: guideStyleProp,
776
+ toggleStyle: styles?.toggle,
777
+ labelStyle: styles?.label,
778
+ getLabel: accessors.getLabel,
779
+ onToggle: toggleId,
780
+ onGo: goTo,
781
+ onOpen: activate,
782
+ register: registerRow,
783
+ });
784
+ rowElems.current.set(row.id, { row, selected: isSelected, el });
785
+ return el;
786
+ };
787
+ /**
788
+ * A row the window said not to build in full yet: the box at its indexed
789
+ * height and none of its content — no guides, no twisty, no label — so
790
+ * the commit answering a flood lands frames before the full rows could.
791
+ * `styles.row` still applies, so row backgrounds hold. Not registered in
792
+ * `rowNodes`: a skeleton must not be measured into the height index, and
793
+ * cannot satisfy a reveal.
794
+ */
795
+ const renderSkeletonRow = (row) => {
513
796
  const isSelected = row.id === current;
514
797
  const color = row.disabled
515
798
  ? theme.textMuted
@@ -523,136 +806,35 @@ onScroll, onViewport, ...boxProps }) {
523
806
  toggle: (open) => toggleId(row.id, row.item, open),
524
807
  select: () => goTo(row),
525
808
  };
526
- const content = [];
527
- // The indent. With no guide seam it is one padding value rather than
528
- // `depth` empty boxes — a tree ten deep would otherwise build ten nodes
529
- // per row to draw nothing.
530
- if (renderGuide && row.depth > 0) {
531
- const edges = branchEdges(row);
532
- for (let level = 0; level < row.depth; level++) {
533
- const guide = {
534
- row: state,
535
- level,
536
- continues: edges[level],
537
- own: level === row.depth - 1,
538
- width: indent,
539
- height: rowHeight,
540
- };
541
- content.push(hx('box', {
542
- key: `guide${level}`,
543
- style: [
544
- s.guide,
545
- { width: indent },
546
- typeof guideStyleProp === 'function'
547
- ? guideStyleProp(guide)
548
- : guideStyleProp,
549
- ],
550
- }, renderGuide(guide)));
551
- }
552
- }
553
- const toggleState = { ...state, size: TWISTY_GLYPH };
554
- content.push(hx('box', {
555
- key: 'toggle',
556
- style: [s.twisty, styles?.toggle],
557
- // The twisty is its own hit target: clicking it opens the branch
558
- // without moving the selection, the way a file browser lets you
559
- // peek inside a folder you have not chosen.
560
- onClick: row.branch
561
- ? (ev) => {
562
- ev.stopPropagation();
563
- toggleId(row.id, row.item);
564
- }
565
- : undefined,
566
- }, renderToggle
567
- ? renderToggle(toggleState)
568
- : row.branch
569
- ? React.createElement(Icon, {
570
- name: row.open
571
- ? 'chevronDown'
572
- : rtl
573
- ? 'chevronLeft'
574
- : 'chevronRight',
575
- size: TWISTY_GLYPH,
576
- // dimmer than the label on a resting row, and the row's own
577
- // ink once it is selected
578
- style: isSelected ? undefined : { color: theme.textMuted },
579
- })
580
- : null));
581
- content.push(renderLabel
582
- ? // Keyed here rather than by the app, for the reason `renderSubtree`
583
- // is: the label sits in an array beside the guides and the twisty,
584
- // and "add a key to the box you return" is not something a render
585
- // prop should have to know.
586
- React.createElement(React.Fragment, { key: 'label' }, renderLabel(state))
587
- : labelNode(accessors.getLabel(row.item), [s.label, styles?.label]));
588
809
  return hx('box', {
589
810
  key: String(row.id),
590
- role: 'treeitem',
591
- 'aria-level': row.depth + 1,
592
- 'aria-selected': isSelected,
593
- 'aria-expanded': row.branch ? row.open : undefined,
594
- 'aria-posinset': row.posInSet,
595
- 'aria-setsize': row.setSize,
596
- // `disabled` rather than `aria-disabled`: on a react-x11 node it is
597
- // the real thing — it clears the AT-SPI ENABLED/SENSITIVE states and
598
- // selects the `:disabled` style block — and there is no aria spelling
599
- // of it to write instead.
600
- disabled: row.disabled || undefined,
601
- // The index the row was drawn at travels with the node, so measuring
602
- // does not have to search a hundred thousand rows for where it is.
603
- // It can go stale — the rows may move before the tick that measures —
604
- // and both this and the height index check it rather than trust it.
605
- ref: (node) => {
606
- if (node)
607
- rowNodes.current.set(row.id, { node, at: row.index });
608
- else
609
- rowNodes.current.delete(row.id);
610
- },
611
- onClick: (ev) => {
612
- if (row.disabled)
613
- return;
614
- goTo(row);
615
- // Select on the first click, open on the second — the gesture every
616
- // file list has. `detail` is the click count the renderer already
617
- // counts for text selection.
618
- if (ev.detail === 2)
619
- activate(row);
620
- },
811
+ 'aria-hidden': true,
621
812
  style: [
622
813
  s.row,
623
- // A floor, not a height. The row grows to whatever its content
624
- // needs a wrapped label, two lines, a thumbnail — and the height
625
- // index reads back what it actually became.
626
- { minHeight: rowHeight },
627
- // The indent is what says "inside", so it is measured from the edge
628
- // the row's label begins at.
629
- { paddingStart: renderGuide ? 4 : 4 + row.depth * indent },
814
+ // Exactly what the index believes, so the spacers and the
815
+ // scrollbar agree with the rows on where everything is.
816
+ { height: index.heightAt(row.index) },
630
817
  {
631
818
  backgroundColor: isSelected ? theme.hoverBackground : 'transparent',
632
- // The row's ink, said once: `color` inherits, so the label takes
633
- // it without being handed it.
634
- color,
635
- },
636
- !row.disabled && {
637
- ':hover': {
638
- backgroundColor: isSelected
639
- ? theme.hoverBackground
640
- : theme.surfaceHover,
641
- },
642
- // The selection only moves on the release, and `:active` marks
643
- // the whole press chain, so a press on the label or the twisty
644
- // still darkens the row it is in.
645
- ':active': {
646
- backgroundColor: isSelected
647
- ? theme.accentActive
648
- : theme.surfaceActive,
649
- },
650
819
  },
651
820
  typeof rowStyleProp === 'function'
652
821
  ? rowStyleProp(state)
653
822
  : rowStyleProp,
654
823
  ],
655
- }, renderContent ? renderContent(state, content) : content);
824
+ },
825
+ // A line of "text" with no text, at the row's own indent, so a band
826
+ // of placeholders reads as the tree arriving rather than a void.
827
+ hx('box', {
828
+ key: 'bar',
829
+ style: [
830
+ s.skeletonBar,
831
+ {
832
+ width: 72 + ((row.index * 37) % 89),
833
+ marginStart: 4 + row.depth * indent + TWISTY + 4,
834
+ backgroundColor: theme.track,
835
+ },
836
+ ],
837
+ }));
656
838
  };
657
839
  /** `layout="nested"`: the same rows, wrapped group by group. */
658
840
  const renderGroup = (group, key) => {
@@ -687,23 +869,100 @@ onScroll, onViewport, ...boxProps }) {
687
869
  style: [s.spacer, { height: above }],
688
870
  }));
689
871
  }
690
- for (let i = first; i < last; i++)
691
- body.push(renderOneRow(rows[i]));
872
+ for (let i = first; i < last; i++) {
873
+ body.push(win.skeletons.has(rows[i].id)
874
+ ? renderSkeletonRow(rows[i])
875
+ : renderOneRow(rows[i]));
876
+ }
692
877
  if (virtualizing && last < rows.length) {
693
878
  body.push(hx('box', {
694
879
  key: 'spacer:after',
695
880
  style: [s.spacer, { height: below }],
696
881
  }));
697
882
  }
883
+ // Rows that left the window leave the cache too, once it has grown
884
+ // well past the window — a scrub across a long list would otherwise
885
+ // hold an element for every row it passed.
886
+ if (rowElems.current.size > (last - first) * 3 + 64) {
887
+ rowElems.current.clear();
888
+ }
698
889
  }
699
- return hx('box', {
890
+ /**
891
+ * The fast-scroll overlay — shown only while placeholders cover enough of
892
+ * the viewport that the user would otherwise be looking at blank rows.
893
+ * The half-viewport threshold keeps a near-miss quiet: a scroll the next
894
+ * frame will absorb is not worth announcing. Once up it stays until the
895
+ * view is whole again, so it does not flicker through the catch-up.
896
+ *
897
+ * A sibling of the scroll pane, never a child: everything inside the pane
898
+ * — absolute children included — is shifted by the scroll, so a pill in
899
+ * there rides away with the very flick it is meant to narrate. Outside,
900
+ * it is painted after the pane on every repaint frame, which is what a
901
+ * scrub produces (a jump past the viewport cannot take the blit fast
902
+ * path), so it stays put while the content flies.
903
+ */
904
+ let scrollHint = null;
905
+ if (virtualizing && rows.length > 0 && view.height > 0) {
906
+ const vFirst = index.indexAt(view.top);
907
+ const vLast = Math.min(rows.length - 1, index.indexAt(view.top + view.height));
908
+ // Two ways in: placeholders covering enough of the viewport that it
909
+ // would otherwise read as blank, or a scrub — the window teleporting
910
+ // while the burst is still in flight, where every commit chases a
911
+ // viewport that has already left and nothing useful can be on screen.
912
+ // Either way only once the catch-up has already *lasted*: a jump the
913
+ // next few frames absorb is not worth announcing, so the pill waits
914
+ // out the show-delay against the catch-up clock. Latched once
915
+ // triggered: `pending` bounces to zero between catch-up commits, and a
916
+ // pill that blinked with it would read as a glitch. It goes when the
917
+ // burst does.
918
+ const engaged = (win.pending > 0 && win.pending * 2 >= vLast - vFirst + 1) ||
919
+ (win.jumped && win.scrolling());
920
+ const lasted = win.catchupSince !== null &&
921
+ Date.now() - win.catchupSince >= scrollHintDelay;
922
+ const show = (engaged && lasted) ||
923
+ (hintShown.current && (win.pending > 0 || win.scrolling()));
924
+ hintShown.current = show;
925
+ if (show) {
926
+ const hintState = {
927
+ row: rows[vFirst],
928
+ from: vFirst + 1,
929
+ to: vLast + 1,
930
+ count: rows.length,
931
+ pending: win.pending,
932
+ since: win.catchupSince ?? Date.now(),
933
+ };
934
+ const content = renderScrollHint
935
+ ? renderScrollHint(hintState)
936
+ : hx('text', { style: { fontSize: 11, color: theme.hoverText } }, `${hintState.from.toLocaleString()} / ${hintState.count.toLocaleString()}`);
937
+ if (content !== null && content !== undefined && content !== false) {
938
+ scrollHint = hx('box', {
939
+ key: 'scroll-hint',
940
+ // The pill duplicates what the scrollbar already tells an
941
+ // assistive technology, and it comes and goes with the
942
+ // catch-up — chatter, not content.
943
+ 'aria-hidden': true,
944
+ style: s.scrollHintLane,
945
+ }, hx('box', {
946
+ style: [s.scrollHint, { backgroundColor: theme.hoverBackground }],
947
+ }, content));
948
+ }
949
+ }
950
+ }
951
+ else {
952
+ hintShown.current = false;
953
+ }
954
+ // The wrapper exists for the overlay: the scroll pane keeps the role, the
955
+ // focus, the refs and the events — everything a `<Tree>` has always put
956
+ // on its root — and the caller's `style` lands out here, where the
957
+ // tree's place in the layout is decided.
958
+ return hx('box', { style: [s.outer, style] }, hx('box', {
700
959
  theme,
701
960
  role: 'tree',
702
961
  // The tree takes the focus, not the row — see the doc comment.
703
962
  focusable: true,
704
963
  ...boxProps,
705
964
  ref: scroller,
706
- style: [s.root, style],
965
+ style: s.root,
707
966
  /**
708
967
  * `preventDefault` is the load-bearing half.
709
968
  *
@@ -726,7 +985,7 @@ onScroll, onViewport, ...boxProps }) {
726
985
  // worth building — and it is also where a page key gets its distance,
727
986
  // so this is measured whether or not the tree virtualizes.
728
987
  onViewport: (ev) => {
729
- setView((prev) => prev.height === ev.height ? prev : { ...prev, height: ev.height });
988
+ win.sized(ev.width, ev.height);
730
989
  // The content just changed size, which is both the moment an owed
731
990
  // scroll can reach further than the clamp let it and the moment the
732
991
  // pane may have re-clamped its offset without saying so. It is not a
@@ -741,11 +1000,9 @@ onScroll, onViewport, ...boxProps }) {
741
1000
  // and an owed reveal must not yank the tree back out from under them
742
1001
  // on the next layout.
743
1002
  reveal.heard(ev.scrollY);
744
- if (virtualizing) {
745
- setView((prev) => prev.top === ev.scrollY ? prev : { ...prev, top: ev.scrollY });
746
- }
1003
+ win.scrolled(ev.scrollY);
747
1004
  onScroll?.(ev);
748
1005
  },
749
- }, body);
1006
+ }, body), scrollHint);
750
1007
  }
751
1008
  //# sourceMappingURL=index.js.map