react-x11 2.10.2 → 2.12.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 (100) hide show
  1. package/README.md +278 -129
  2. package/package.json +10 -3
  3. package/src/Reconciler.js +15 -17
  4. package/src/a11y.js +2 -2
  5. package/src/anchor.js +7 -5
  6. package/src/bootstrap.js +14 -0
  7. package/src/clientmessage.js +1 -1
  8. package/src/cocoa/app.js +304 -49
  9. package/src/cocoa/bezels.js +175 -30
  10. package/src/cocoa/dnd.js +27 -13
  11. package/src/cocoa/fonts.js +3 -3
  12. package/src/cocoa/glarea.js +20 -3
  13. package/src/cocoa/main.d.ts +8 -0
  14. package/src/cocoa/main.js +43 -0
  15. package/src/cocoa/panehost.js +15 -5
  16. package/src/cocoa/presenter.js +13 -9
  17. package/src/cocoa/promotion.js +4 -7
  18. package/src/cocoa/relaunch.js +207 -0
  19. package/src/cocoa/screencolor.js +62 -0
  20. package/src/cocoa/threaded.js +246 -0
  21. package/src/cocoa/window.js +256 -42
  22. package/src/components/Select.js +2 -2
  23. package/src/components/anchor.js +3 -3
  24. package/src/components/native.js +12 -7
  25. package/src/components/theme.js +2 -2
  26. package/src/debug.js +1 -1
  27. package/src/decorations.js +1 -1
  28. package/src/editmenu.js +2 -2
  29. package/src/errors.js +46 -0
  30. package/src/events.js +6 -6
  31. package/src/foreignnodes.js +3 -2
  32. package/src/frames.js +2 -2
  33. package/src/glnodes.js +1 -1
  34. package/src/grid.js +1653 -0
  35. package/src/host.d.ts +230 -0
  36. package/src/host.js +11 -3
  37. package/src/imagesource.js +1 -1
  38. package/src/index.d.ts +21 -4
  39. package/src/index.js +9 -1
  40. package/src/layouts.js +721 -0
  41. package/src/node.d.ts +4 -2
  42. package/src/node.js +19 -21
  43. package/src/nodes/animation.js +644 -0
  44. package/src/nodes/box.js +21 -0
  45. package/src/nodes/boxpaint.js +473 -0
  46. package/src/nodes/canvas.js +269 -0
  47. package/src/nodes/cascade.js +600 -0
  48. package/src/nodes/damage.js +183 -0
  49. package/src/nodes/edithistory.js +124 -0
  50. package/src/nodes/editmenupopup.js +260 -0
  51. package/src/nodes/hittest.js +185 -0
  52. package/src/nodes/image.js +266 -0
  53. package/src/nodes/install.js +75 -0
  54. package/src/nodes/invalidate.js +465 -0
  55. package/src/nodes/kinds.js +31 -0
  56. package/src/nodes/layout.js +439 -0
  57. package/src/nodes/layouthost.js +949 -0
  58. package/src/nodes/node.js +868 -0
  59. package/src/nodes/paint.js +466 -0
  60. package/src/nodes/position.js +366 -0
  61. package/src/nodes/preedit.js +127 -0
  62. package/src/nodes/queries.js +330 -0
  63. package/src/nodes/rects.js +102 -0
  64. package/src/nodes/scrollable.js +891 -0
  65. package/src/nodes/scrollbars.js +138 -0
  66. package/src/nodes/scrollblit.js +1034 -0
  67. package/src/nodes/selectable.js +142 -0
  68. package/src/nodes/styling.js +225 -0
  69. package/src/nodes/text.js +649 -0
  70. package/src/nodes/textarea.js +391 -0
  71. package/src/nodes/textinput.js +1146 -0
  72. package/src/nodes/util.js +17 -0
  73. package/src/nodes/window/anchoring.js +161 -0
  74. package/src/nodes/window/capabilities.js +190 -0
  75. package/src/nodes/window/debugpaint.js +83 -0
  76. package/src/nodes/window/droptarget.js +145 -0
  77. package/src/nodes/window/floors.js +577 -0
  78. package/src/nodes/window/flush.js +334 -0
  79. package/src/nodes/window/hints.js +482 -0
  80. package/src/nodes/window/listeners.js +222 -0
  81. package/src/nodes/window/popup.js +71 -0
  82. package/src/nodes/window/size.js +591 -0
  83. package/src/nodes/window/window.js +945 -0
  84. package/src/palette.js +1 -1
  85. package/src/registry.js +7 -3
  86. package/src/screencolor.js +212 -38
  87. package/src/screencolorhooks.js +6 -2
  88. package/src/styles.js +137 -15
  89. package/src/svgnodes.js +2 -1
  90. package/src/testing/harness.js +2 -2
  91. package/src/textselection.js +5 -3
  92. package/src/trace-registry.js +1 -1
  93. package/src/types/components.d.ts +38 -6
  94. package/src/types/elements.d.ts +11 -1
  95. package/src/types/nodes.d.ts +33 -5
  96. package/src/types/screencolor.d.ts +20 -14
  97. package/src/types/style.d.ts +94 -3
  98. package/src/windowstate.js +1 -1
  99. package/src/yoga.js +1 -1
  100. package/src/nodes.js +0 -13120
@@ -0,0 +1,891 @@
1
+ // The Scrollable mixin: `overflow: 'scroll'` with the wheel, the keys, the
2
+ // bars and the a11y role wired. <box> and <window> use it, and a registered
3
+ // element can too — `class MyPane extends Scrollable(Node)`.
4
+
5
+ import { Yoga } from '../yoga.js';
6
+ import { WHEEL_NOTCH_PX } from '../events.js';
7
+ import { callHandler } from '../errors.js';
8
+ import {
9
+ XK_HOME,
10
+ XK_LEFT,
11
+ XK_UP,
12
+ XK_RIGHT,
13
+ XK_DOWN,
14
+ XK_PAGE_UP,
15
+ XK_PAGE_DOWN,
16
+ XK_END,
17
+ XK_SPACE,
18
+ } from '../keysyms.js';
19
+ import { DAMAGE_SLOP, layoutDiff } from './damage.js';
20
+ import { describeSize, offsetInParent } from './layout.js';
21
+ import { insetRect, intersectRects, rectsOverlap } from './rects.js';
22
+ import {
23
+ SCROLLBAR_WIDTH,
24
+ scrollbarGeometry,
25
+ along,
26
+ scrollbarHit,
27
+ paintScrollbarThumb,
28
+ } from './scrollbars.js';
29
+ import { BLIT_POISONED } from './scrollblit.js';
30
+
31
+ const clampScroll = (v, max) => Math.min(Math.max(0, v), max);
32
+
33
+ /**
34
+ * One axis of a scroll request held for a pass
35
+ * (`Scrollable._holdScrollTo`): `{ base, steps }`, the request the extent in
36
+ * hand could not answer and the relative ones made after it. `to` is the new
37
+ * request on this axis (null leaves the axis alone), `step` its delta when
38
+ * it was relative.
39
+ */
40
+ function holdAxis(held, to, step, owed, max) {
41
+ if (to == null) return held ?? null;
42
+ if (held && step != null) {
43
+ return { base: held.base, steps: [...held.steps, step] };
44
+ }
45
+ return owed && to > max ? { base: to, steps: [] } : null;
46
+ }
47
+
48
+ /** Where a held axis lands against the extent a pass measured: the base,
49
+ * clamped, then each step from there, clamped in turn — the answers a pane
50
+ * that already had this extent would have given one request at a time. */
51
+ function replayHeld({ base, steps }, max) {
52
+ let at = clampScroll(base, max);
53
+ for (const step of steps) at = clampScroll(at + step, max);
54
+ return at;
55
+ }
56
+
57
+ // Every box and every window now answers `_scrollbars()`, and almost none of
58
+ // them has any: the shared empty keeps that answer allocation-free on a path
59
+ // walked per node per hit test.
60
+ const EMPTY_SCROLLBARS = Object.freeze([]);
61
+
62
+ /**
63
+ * Scrolling, as a style rather than as a species of node.
64
+ *
65
+ * `overflow: 'scroll'` turns a `<box>` — or a `<window>` — into a clipped
66
+ * viewport over its own overflowing content: the offset is applied during
67
+ * absolutize, so painting and hit testing see already-shifted rects. Wheel
68
+ * events scroll the nearest one by default (see EventManager), and
69
+ * `scrollTo`/`scrollBy`/`scrollIntoView` are on the ref.
70
+ *
71
+ * Everything here is **inert until the style says scroll**, which is what
72
+ * lets it live on the ordinary container instead of behind an element of its
73
+ * own: `_maxScroll` answers 0, so `scrollTo` is a no-op, no bar has geometry,
74
+ * nothing is a tab stop, and `absolutize` takes the plain path. A second gate
75
+ * sits behind the first — most of the visible behaviour also asks whether
76
+ * there is anything to scroll *right now* — so a viewport whose content fits
77
+ * really is an ordinary clipped box, and grows a thumb and a tab stop the
78
+ * moment its content outgrows it.
79
+ *
80
+ * A mixin rather than a base class because the two elements that scroll do
81
+ * not share one: `<box>` extends Node directly and `<window>` is its own
82
+ * world. Deliberately not on Node itself — `<textinput>`/`<textarea>` carry
83
+ * their own `_scrollbar` and `focusableByDefault` with different meanings,
84
+ * and inheriting these would collide with both.
85
+ */
86
+ export const Scrollable = (Base) =>
87
+ class extends Base {
88
+ constructor(...args) {
89
+ super(...args);
90
+ this.scrollY = 0;
91
+ this.scrollX = 0;
92
+ this.contentHeight = 0;
93
+ this.contentWidth = 0;
94
+ // `measureScrollContent` owed a fresh answer — true until the first
95
+ // layout pass measures, and re-raised by any change yoga cannot see
96
+ // (`_markScrollMeasureDirty`, issue #405)
97
+ this._scrollMeasureDirty = true;
98
+ }
99
+
100
+ /**
101
+ * Does this node scroll what overflows it? The one gate everything below
102
+ * reads, and the reason `overflow: 'scroll'` and `overflow: 'hidden'`
103
+ * are now genuinely different things: both clip, only this one scrolls.
104
+ *
105
+ * The layout defaults that go with it — `flex-basis: 0`, `min-width: 0`,
106
+ * `min-height: 0` — are folded into the resolved style by
107
+ * `resolveComputedStyle` (styles.js), so they travel through the same
108
+ * diff as any other style and come back off when the overflow does.
109
+ */
110
+ isScroller() {
111
+ return this.style.overflow === 'scroll';
112
+ }
113
+
114
+ /**
115
+ * Stopped being a scroll container: an offset nothing will clamp again
116
+ * would otherwise keep the content shifted forever. CSS loses the scroll
117
+ * position the same way when a box stops scrolling.
118
+ */
119
+ _overflowChanged() {
120
+ // whichever way the style flipped, the next scrolling pass starts
121
+ // from a fresh measurement
122
+ this._scrollMeasureDirty = true;
123
+ if (this.isScroller()) return;
124
+ this._scrollIntoViewTarget = null;
125
+ this._scrollToTarget = null;
126
+ this._childOrigin = null;
127
+ if (this.scrollX === 0 && this.scrollY === 0) return;
128
+ this.scrollX = 0;
129
+ this.scrollY = 0;
130
+ this._invalidateLayout('scroll');
131
+ }
132
+
133
+ absolutize(originX, originY) {
134
+ this._placed = true;
135
+ if (!this.yoga) return;
136
+ this._assignAbs(
137
+ originX + this.yoga.getComputedLeft(),
138
+ originY + this.yoga.getComputedTop(),
139
+ this.yoga.getComputedWidth(),
140
+ this.yoga.getComputedHeight(),
141
+ );
142
+ if (this.props.onLayout) this._reportLayout();
143
+ this._absolutizeChildren(this.abs.x, this.abs.y);
144
+ }
145
+
146
+ /**
147
+ * Place the children, shifted by the scroll offset when there is one.
148
+ * Split out of `absolutize` because a `<window>` writes its own `abs`
149
+ * during flush and then walks its children from (0, 0) — the same walk,
150
+ * reached by a different route.
151
+ */
152
+ _absolutizeChildren(originX, originY) {
153
+ if (!this.isScroller()) {
154
+ // a layout host's children go where its algorithm put them
155
+ if (this._host !== null) {
156
+ this._absolutizeHostChildren();
157
+ return;
158
+ }
159
+ for (const child of this.children) {
160
+ if (!child.isWindow) child.absolutize(originX, originY);
161
+ }
162
+ return;
163
+ }
164
+ const rtl = this.direction === 'rtl';
165
+ // A pure-scroll pass re-learns nothing by walking (issue #405): the
166
+ // content reach and every child's place *inside* the pane only change
167
+ // when layout inside the pane changes. Yoga's own has-new-layout flag
168
+ // is the witness — consumed here and nowhere else — set by any pass
169
+ // that laid this node or anything under it out again, and left clear
170
+ // by one that merely scrolled. `_scrollMeasureDirty` covers the one
171
+ // route yoga cannot see: an element that paints its own content
172
+ // growing its extent (docs/extending.md), announced through
173
+ // `invalidate(true, this, 'scroll')`. The root's yoga node re-flags
174
+ // on every pass, so a `<window overflow='scroll'>` always takes the
175
+ // full walk — the pane that holds an app's long list is a box.
176
+ const clean =
177
+ this._childOrigin != null &&
178
+ !this._scrollMeasureDirty &&
179
+ !this.yoga.hasNewLayout();
180
+ if (!clean) {
181
+ const size = this.measureScrollContent();
182
+ if (!Number.isFinite(size?.width) || !Number.isFinite(size?.height)) {
183
+ // A NaN here does not throw on its own: it becomes a NaN max
184
+ // scroll, a NaN offset, and every child laid out at NaN — a whole
185
+ // tree gone with nothing naming the element that did it.
186
+ throw new Error(
187
+ `react-x11: <${this.kind}>.measureScrollContent() must return ` +
188
+ '{ width, height } as finite numbers; it returned ' +
189
+ `${describeSize(size)}. Return { width: 0, height: 0 } for ` +
190
+ 'content that has not arrived yet.',
191
+ );
192
+ }
193
+ this.contentWidth = size.width;
194
+ this.contentHeight = size.height;
195
+ this._scrollMeasureDirty = false;
196
+ this.yoga.markLayoutSeen();
197
+ }
198
+ // The moves layout makes on its own, with nobody's call to report them
199
+ // from: a `scrollTo` held for this pane's first pass landing, a
200
+ // `scrollIntoView` resolving against the geometry this pass produced,
201
+ // and the clamp pulling the offset back when the content shrank, or
202
+ // the viewport grew, under it. A browser fires `scroll` for each, so
203
+ // `onScroll` hears of them too, once the pass is over.
204
+ const from = { x: this.scrollX, y: this.scrollY };
205
+ // A scrollTo held for this pass lands first, so a node asked into view
206
+ // in the same frame is brought in from where that scroll put the pane:
207
+ // the order the two have on a laid-out pane, where scrollTo applies at
208
+ // once and scrollIntoView waits for the pass.
209
+ this._resolveScrollTo();
210
+ this._resolveScrollIntoView();
211
+ this.scrollY = clampScroll(this.scrollY, this._maxScroll('y'));
212
+ this.scrollX = clampScroll(this.scrollX, this._maxScroll('x'));
213
+ this._reportViewport();
214
+ this._reportScrollTo(from);
215
+ // `scrollX` is how far the content has moved **from its start**, which
216
+ // is the right-hand edge in RTL — so scrolling shifts the children the
217
+ // other way. Keeping it a distance rather than a coordinate is what
218
+ // makes `scrollTo({x: 0})` mean "back to the beginning" in both
219
+ // directions, and keeps every clamp and every max in one sign. What
220
+ // moves pixels with the content does not share it: the scroll blit
221
+ // asks `_blitShift`, which asks the direction.
222
+ const ox = rtl ? originX + this.scrollX : originX - this.scrollX;
223
+ const oy = originY - this.scrollY;
224
+ // The layout diff and a scroll would double-report each other: a scroll
225
+ // is a uniform shift of everything below this viewport, already claimed
226
+ // as the viewport itself (or narrowed to the exposed strip by the blit),
227
+ // and per-child old/new claims would re-widen the very frame the blit
228
+ // narrows. So when the children's origin moved, the walk below runs
229
+ // with the diff off. When it did not move, a child that moved did so by
230
+ // real layout — claim it, but clipped to the viewport: ink below the
231
+ // fold never reaches the surface, and an unclipped claim would repaint
232
+ // whatever unrelated UI sits under this node's off-viewport extent.
233
+ const wasOrigin = this._childOrigin;
234
+ const shifted = wasOrigin && (wasOrigin.x !== ox || wasOrigin.y !== oy);
235
+ this._childOrigin = { x: ox, y: oy };
236
+ if (clean) {
237
+ // The fast path (issue #405): nothing inside was laid out, so every
238
+ // child sits exactly where the last pass put it, shifted by however
239
+ // far the origin moved — one uniform translation instead of a
240
+ // per-node yoga re-derivation. The layout diff is owed nothing by
241
+ // construction: under a blit ledger the shifted diff's claims are
242
+ // the deviations from this very translation, and a clean pane has
243
+ // none — the walk below lands every node where the diff would have
244
+ // reported silence.
245
+ if (!shifted) return;
246
+ const dx = ox - wasOrigin.x;
247
+ const dy = oy - wasOrigin.y;
248
+ for (const child of this.children) {
249
+ if (!child.isWindow) child._shiftAbs(dx, dy);
250
+ }
251
+ return;
252
+ }
253
+ const outer = layoutDiff.sink;
254
+ const outerShift = layoutDiff.shift;
255
+ const ledger = shifted && this._blitLedgerOpen();
256
+ if (outer) {
257
+ if (ledger) {
258
+ // The blit's own ledger takes this walk (issue #398). The shift
259
+ // below is what makes the diff worth running under a scroll at
260
+ // all: without it every child reports the move the blit is about
261
+ // to make for them, and the claims add up to the viewport. What
262
+ // is left is the virtualized list's real frame — the rows that
263
+ // entered, the ones that left, a spacer that resized — and it
264
+ // goes to the ledger rather than to `outer`, whose claims are
265
+ // what `layoutMoved` reads as "this frame is not a pure scroll".
266
+ const vp = insetRect(this.abs, -DAMAGE_SLOP);
267
+ layoutDiff.sink = (rect) => {
268
+ const clipped = intersectRects(rect, vp);
269
+ if (clipped && !this._recordBlitClaim(clipped)) {
270
+ this._pendingBlitFrom = BLIT_POISONED;
271
+ }
272
+ };
273
+ layoutDiff.shift = { x: ox - wasOrigin.x, y: oy - wasOrigin.y };
274
+ } else if (shifted) {
275
+ layoutDiff.sink = null;
276
+ } else {
277
+ const vp = insetRect(this.abs, -DAMAGE_SLOP);
278
+ layoutDiff.sink = (rect) => {
279
+ const clipped = intersectRects(rect, vp);
280
+ if (clipped) outer(clipped);
281
+ };
282
+ }
283
+ }
284
+ try {
285
+ for (const child of this.children) {
286
+ if (!child.isWindow) {
287
+ child.absolutize(ox, oy);
288
+ }
289
+ }
290
+ } finally {
291
+ layoutDiff.sink = outer;
292
+ layoutDiff.shift = outerShift;
293
+ }
294
+ }
295
+
296
+ /**
297
+ * A scroller inside a shifting subtree does not ride the translation
298
+ * blindly: its box moves rigidly, but its children's origin also
299
+ * carries the scroll offsets, which may have changed again this very
300
+ * frame — a wheel on a nested pane while an outer one scrolls.
301
+ * Re-entering `_absolutizeChildren` folds both into one delta, and
302
+ * re-runs the gate, so a nested pane that is not clean still walks
303
+ * properly. (Reached only under an outer pane's fast path, which
304
+ * proved nothing in here was laid out — the nested gate can only
305
+ * decline over its own `_scrollMeasureDirty`.)
306
+ */
307
+ _shiftChildren(dx, dy) {
308
+ if (!this.isScroller()) return super._shiftChildren(dx, dy);
309
+ this._absolutizeChildren(this.abs.x, this.abs.y);
310
+ }
311
+
312
+ /**
313
+ * Tell the owner how big the viewport and the content turned out, when
314
+ * either changes. Layout happens on the frame clock, *after* the commit
315
+ * that mounted the node, so an effect cannot read this off the ref —
316
+ * which is exactly what a list needs before it can decide how many rows
317
+ * are worth building. Fired from layout rather than from scrolling, so
318
+ * it also arrives for a list nobody has scrolled yet.
319
+ */
320
+ _reportViewport() {
321
+ // logical, like onScroll's payload: finder.jsx divides this height by
322
+ // a row height it wrote in a style
323
+ const s = this.scale;
324
+ const next = {
325
+ width: this.abs.width / s,
326
+ height: this.abs.height / s,
327
+ contentWidth: this.contentWidth / s,
328
+ contentHeight: this.contentHeight / s,
329
+ };
330
+ const last = this._lastViewport;
331
+ if (
332
+ last &&
333
+ last.width === next.width &&
334
+ last.height === next.height &&
335
+ last.contentWidth === next.contentWidth &&
336
+ last.contentHeight === next.contentHeight
337
+ ) {
338
+ return;
339
+ }
340
+ this._lastViewport = next;
341
+ // during layout: defer, or a setState from the handler would re-enter
342
+ // the pass that is still running
343
+ const notify = this.props.onViewport;
344
+ if (notify) setImmediate(() => !this.destroyed && notify(next));
345
+ }
346
+
347
+ /**
348
+ * How far the content reaches — `scrollWidth`/`scrollHeight`, and what
349
+ * everything below scrolls against: the maxima, the bars, the keys.
350
+ *
351
+ * The default measures the **children**, through the subtree rather than
352
+ * off the direct ones. A row that stretches to the viewport while its own
353
+ * cells overflow it — a table, in other words — reports the viewport
354
+ * width at the top level and says nothing about the cells, so a shallow
355
+ * measurement would find nothing to scroll. Anything that clips its own
356
+ * children ends the walk: their overflow is that node's business.
357
+ *
358
+ * `width` is how far the content reaches from the edge it *starts* at,
359
+ * which is the right-hand one under `direction: 'rtl'` — yoga lays an
360
+ * overflowing RTL row out at negative offsets, so the reach that matters
361
+ * there is how far left of zero it got, not how far right. An element
362
+ * measuring its own drawing answers the same question and never has to
363
+ * ask which direction it is in.
364
+ *
365
+ * **Override it when the content is pixels rather than nodes.** An
366
+ * element that paints its own content — an editor drawing lines of text,
367
+ * a terminal, a canvas-backed table — has no children to walk, so the
368
+ * default measures 0 and the viewport clamps to nothing however far the
369
+ * drawing actually goes. Answering here is the whole of joining in: the
370
+ * wheel, the scrollbars, the scroll keys and the AT-SPI scroll pane all
371
+ * read the numbers this returns (docs/extending.md).
372
+ *
373
+ * Called at most once per layout pass, from `absolutize`, so it may
374
+ * read yoga geometry but must not invalidate or paint — and cached
375
+ * across passes that laid nothing inside the pane out again (issue
376
+ * #405): a pass that merely scrolled reuses the last answer, since a
377
+ * scroll cannot change how far the content reaches. An element whose
378
+ * extent changed by a route layout never saw — rows arrived, a line
379
+ * was typed — announces it with `invalidate(true, this, 'scroll')`,
380
+ * and the next pass asks again.
381
+ */
382
+ measureScrollContent() {
383
+ const rtl = this.direction === 'rtl';
384
+ const width = this.yoga.getComputedWidth();
385
+ let start = 0;
386
+ let bottom = 0;
387
+ const walk = (node, dx, dy) => {
388
+ for (const child of node.children) {
389
+ if (child.isWindow || !child.yoga || child.hidden) continue;
390
+ const offset = offsetInParent(child);
391
+ const x = dx + offset.x;
392
+ const y = dy + offset.y;
393
+ const w = child.yoga.getComputedWidth();
394
+ start = Math.max(start, rtl ? width - x : x + w);
395
+ bottom = Math.max(bottom, y + child.yoga.getComputedHeight());
396
+ if (!child.clipsChildren()) walk(child, x, y);
397
+ }
398
+ };
399
+ walk(this, 0, 0);
400
+ // the end padding is part of the content box a browser scrolls to, and
401
+ // it is the one part of it yoga has already resolved for us — on the
402
+ // left in RTL, since that is the end there
403
+ return {
404
+ width:
405
+ start +
406
+ this.yoga.getComputedPadding(rtl ? Yoga.EDGE_LEFT : Yoga.EDGE_RIGHT),
407
+ height: bottom + this.yoga.getComputedPadding(Yoga.EDGE_BOTTOM),
408
+ };
409
+ }
410
+
411
+ /**
412
+ * How far this axis can scroll — 0 for a node the style does not make a
413
+ * scroll container, which is the gate the whole public surface rests on:
414
+ * `scrollTo` clamps to nothing, no bar has geometry, and nothing is a
415
+ * tab stop, without any of them testing the style themselves.
416
+ */
417
+ _maxScroll(axis) {
418
+ if (!this.isScroller()) return 0;
419
+ return axis === 'x'
420
+ ? Math.max(0, this.contentWidth - this.abs.width)
421
+ : Math.max(0, this.contentHeight - this.abs.height);
422
+ }
423
+
424
+ /**
425
+ * `scrollTo(y)` scrolls vertically, as it always has; `scrollTo({x, y})`
426
+ * moves either axis, leaving out whichever is omitted.
427
+ */
428
+ /** Public entry, logical pixels — application code writes `scrollTo(120)`
429
+ * in the same unit as its styles. Internal callers hold device offsets
430
+ * and use `_scrollToDevice`/`_scrollByDevice` instead (src/scale.js). */
431
+ scrollTo(to) {
432
+ const s = this.scale;
433
+ this._scrollToDevice(
434
+ typeof to === 'number'
435
+ ? { y: to * s }
436
+ : {
437
+ x: to?.x == null ? undefined : to.x * s,
438
+ y: to?.y == null ? undefined : to.y * s,
439
+ },
440
+ );
441
+ }
442
+
443
+ _scrollToDevice(want, by = null) {
444
+ const maxX = this._maxScroll('x');
445
+ const maxY = this._maxScroll('y');
446
+ const next = {
447
+ x: want.x == null ? this.scrollX : clampScroll(want.x, maxX),
448
+ y: want.y == null ? this.scrollY : clampScroll(want.y, maxY),
449
+ };
450
+ const moved = next.x !== this.scrollX || next.y !== this.scrollY;
451
+ const holding = this._holdScrollTo(want, by, maxX, maxY);
452
+ if (!moved && !holding) return;
453
+ const root = this.root;
454
+ // A pane no pass has placed arms nothing: its first pass is a layout
455
+ // change rather than a pure scroll.
456
+ if (root && this._childOrigin != null) {
457
+ // Arming is the one moment the evidence still exists: the viewport
458
+ // claim recorded below coalesces earlier claims into itself
459
+ // (addDamageRect keeps the list disjoint), after which a change
460
+ // inside the viewport is indistinguishable from the scroll's own
461
+ // claim — the blind spot the claim-time cancel in
462
+ // WindowNode.invalidate cannot cover (react-x11#295). The scroll
463
+ // has not claimed yet, so damage already overlapping this viewport
464
+ // is foreign by construction: poison the frame instead of arming,
465
+ // and the full-viewport repaint below stays in force.
466
+ const arming = this._pendingBlitFrom == null;
467
+ // The ledger this frame's changes inside the viewport are written
468
+ // to (issue #398). Opened with the blit and read by
469
+ // _applyScrollBlits, which clears it beside the origin.
470
+ if (arming) this._blitLedger = [];
471
+ if (arming && Array.isArray(root._damage)) {
472
+ const zone = insetRect(this.abs, -(DAMAGE_SLOP * 2 + 1));
473
+ for (const rect of root._damage) {
474
+ // Already coalesced, so these rects are as coarse as the frame
475
+ // has made them — which the ledger reads conservatively: a blob
476
+ // that swallowed the viewport says so and poisons, exactly as
477
+ // this gate used to for every claim it saw.
478
+ if (rectsOverlap(rect, zone) && !this._recordBlitClaim(rect)) {
479
+ this._pendingBlitFrom = BLIT_POISONED;
480
+ break;
481
+ }
482
+ }
483
+ }
484
+ // An element that also shifted its own drawing this frame
485
+ // (`scrollContents`, issue #303) is two shifts of the same pixels,
486
+ // and a frame can only have one.
487
+ if (this._pendingBlitContents) this._pendingBlitFrom = BLIT_POISONED;
488
+ // The offsets whose pixels are on screen, captured before the first
489
+ // change of the frame: the frame's blit fast path (issue #138) shifts
490
+ // from *these* to wherever layout settles, however many scrollTo
491
+ // calls land in between.
492
+ this._pendingBlitFrom ??= { x: this.scrollX, y: this.scrollY };
493
+ (root._pendingScrolls ??= new Set()).add(this);
494
+ // ... and the claim about to be recorded is the scroll itself, not a
495
+ // reason to un-blit it
496
+ root._scrollClaim = this;
497
+ }
498
+ this.scrollX = next.x;
499
+ this.scrollY = next.y;
500
+ if (moved) this.props.onScroll?.(this._scrollEvent());
501
+ // A scroll reflows this viewport's contents and nothing else, and the
502
+ // viewport clips them, so the damage is this node's own rect. It is a
503
+ // layout change all the same — children's absolute positions move — hence
504
+ // both arguments. Unbounded, every wheel notch repainted the whole window,
505
+ // which is the whole cost of scrolling: the client work is negligible next
506
+ // to what the server then has to redraw. (When the frame turns out to be
507
+ // a *pure* scroll, _applyScrollBlits later narrows this claim to the
508
+ // exposed strip and blits the rest — see WindowNode.)
509
+ this.root?.invalidate(true, this, 'scroll');
510
+ if (root) root._scrollClaim = null;
511
+ }
512
+
513
+ /**
514
+ * Keep the part of a request the extent in hand cannot answer, for the
515
+ * pass that will measure one that can. `_scrollToDevice` clamps against
516
+ * `contentWidth`/`contentHeight` and `abs` as the last pass left them,
517
+ * and two kinds of pane have nothing better there yet:
518
+ *
519
+ * - one no pass has placed as a scroller (`_childOrigin` is the
520
+ * witness): on mount those are still the zeros they were built with,
521
+ * and a box that starts scrolling in the same commit never measured
522
+ * them. Restoring a list's position from a mount-time effect is the
523
+ * case.
524
+ * - one whose next pass is already owed (`needsLayout`): rows mounted in
525
+ * the commit the request comes from are not in the extent yet, so a
526
+ * follow to the end from a layout effect stopped at the old end.
527
+ *
528
+ * What the extent in hand can answer still lands at once, with its
529
+ * `onScroll` and its blit origin. The request is kept as well, and
530
+ * `_resolveScrollTo` answers it again against what the pass measures,
531
+ * before anything is placed.
532
+ *
533
+ * Per axis, a request replaces whatever was held on that axis, and a
534
+ * relative one (`by`) made while something is held is kept as a step
535
+ * after it, so the pass replays the frame's requests the way a pane with
536
+ * a fresh extent would have taken them. Returns whether anything is
537
+ * held.
538
+ */
539
+ _holdScrollTo(want, by, maxX, maxY) {
540
+ const root = this.root;
541
+ const owed =
542
+ this.isScroller() &&
543
+ (this._childOrigin == null ||
544
+ (root != null && root.needsLayout && !root._inFlush));
545
+ const was = this._scrollToTarget;
546
+ const x = holdAxis(was?.x, want.x, by?.x, owed, maxX);
547
+ const y = holdAxis(was?.y, want.y, by?.y, owed, maxY);
548
+ this._scrollToTarget = x || y ? { x, y } : null;
549
+ if (!this._scrollToTarget) return false;
550
+ if (root) (root._heldScrolls ??= new Set()).add(this);
551
+ return true;
552
+ }
553
+
554
+ /**
555
+ * Answer a held request (`_holdScrollTo`) against the extent this pass
556
+ * has just measured: each held axis's base, clamped, then each step
557
+ * after it, clamped in turn. Its `onScroll` is the pass's, like every
558
+ * move layout makes (see `_absolutizeChildren`).
559
+ */
560
+ _resolveScrollTo() {
561
+ const held = this._scrollToTarget;
562
+ if (!held) return;
563
+ this._scrollToTarget = null;
564
+ this.root?._heldScrolls?.delete(this);
565
+ if (held.x) this.scrollX = replayHeld(held.x, this._maxScroll('x'));
566
+ if (held.y) this.scrollY = replayHeld(held.y, this._maxScroll('y'));
567
+ }
568
+
569
+ /**
570
+ * `onScroll` for an offset a layout pass moved — a held `scrollTo`
571
+ * landing, a `scrollIntoView` resolving, or the clamp when the content
572
+ * shrank or the viewport grew (see `_absolutizeChildren`) — when the
573
+ * pass left the pane somewhere other than `from`. Deferred like
574
+ * `onViewport`, since a setState from the handler would re-enter the
575
+ * pass, so it arrives after the frame that first shows the new offset.
576
+ * The payload is read on delivery, not now: a wheel landing in between
577
+ * has already reported where it went, and a payload from before it
578
+ * would leave the handler behind the pane.
579
+ */
580
+ _reportScrollTo(from) {
581
+ if (from.x === this.scrollX && from.y === this.scrollY) return;
582
+ setImmediate(() => {
583
+ const notify = this.props.onScroll;
584
+ if (this.destroyed || !notify) return;
585
+ callHandler(this, 'onScroll', notify, this._scrollEvent());
586
+ });
587
+ }
588
+
589
+ /** `onScroll`'s payload, for the offsets in force. The handler is
590
+ * application code, so it is logical like every payload: finder.jsx's
591
+ * row virtualisation divides scrollY by a row height it wrote in a
592
+ * style, and those must be the same unit. */
593
+ _scrollEvent() {
594
+ const s = this.scale;
595
+ return {
596
+ scrollX: this.scrollX / s,
597
+ scrollY: this.scrollY / s,
598
+ contentWidth: this.contentWidth / s,
599
+ contentHeight: this.contentHeight / s,
600
+ viewportWidth: this.abs.width / s,
601
+ viewportHeight: this.abs.height / s,
602
+ };
603
+ }
604
+
605
+ /**
606
+ * Is there room to move on the axis this delta names? The first half of
607
+ * the wheel's chain protocol (`canScroll` then `scrollBy`, see
608
+ * docs/extending.md): a scroll container that fits its content answers
609
+ * no and hands the gesture to the next one out, the way a browser does.
610
+ *
611
+ * Position is deliberately not part of the answer — a viewport scrolled
612
+ * to its bottom still owns the wheel, rather than passing the rest of a
613
+ * flick to whatever is behind it.
614
+ */
615
+ canScroll(dx, dy) {
616
+ if (dx && this._maxScroll('x') > 0) return true;
617
+ if (dy && this._maxScroll('y') > 0) return true;
618
+ return false;
619
+ }
620
+
621
+ /** `scrollBy(dy)`, or `scrollBy({x, y})` for either axis. Logical, like
622
+ * `scrollTo`. */
623
+ scrollBy(by) {
624
+ const s = this.scale;
625
+ const step =
626
+ typeof by === 'number'
627
+ ? { y: by * s }
628
+ : {
629
+ x: by?.x == null ? undefined : by.x * s,
630
+ y: by?.y == null ? undefined : by.y * s,
631
+ };
632
+ this._scrollToDevice(
633
+ {
634
+ x: step.x == null ? undefined : this.scrollX + step.x,
635
+ y: step.y == null ? undefined : this.scrollY + step.y,
636
+ },
637
+ step,
638
+ );
639
+ }
640
+
641
+ /** The wheel's and the key handler's entry: whole device pixels, which
642
+ * is what keeps the scroll blit on the pixel grid at any scale. */
643
+ _scrollByDevice(dx, dy) {
644
+ this._scrollToDevice(
645
+ {
646
+ x: dx ? this.scrollX + dx : undefined,
647
+ y: dy ? this.scrollY + dy : undefined,
648
+ },
649
+ { x: dx || undefined, y: dy || undefined },
650
+ );
651
+ }
652
+
653
+ /**
654
+ * A box with something to scroll is a tab stop, so a pane of
655
+ * *unfocusable* content — a log, a long `<text>`, a rendered document —
656
+ * can be read without a pointer. Before this the only way to scroll one was the
657
+ * wheel, which is a WCAG 2.1.1 failure on the most ordinary layout the
658
+ * library has.
659
+ *
660
+ * Conditional on purpose: a scroll box that fits its content is an
661
+ * ordinary clipped box, and stopping Tab on it would be a tab stop that
662
+ * does nothing. It is answered from the current layout, so a pane that
663
+ * grows past its viewport becomes reachable the moment it does.
664
+ */
665
+ get focusableByDefault() {
666
+ return this._scrollsWithKeys();
667
+ }
668
+
669
+ /** Is there anything here for the scroll keys to move? Separate from
670
+ * `focusableByDefault` because a box can now be a focus target for
671
+ * another reason — a `selectable` document is one (a11y.js) — and a
672
+ * document that does not scroll must still leave the arrows alone. */
673
+ _scrollsWithKeys() {
674
+ return this._maxScroll('y') > 0 || this._maxScroll('x') > 0;
675
+ }
676
+
677
+ /**
678
+ * The keys a scroll pane answers, matching what every desktop toolkit
679
+ * does: arrows by a wheel notch, PageUp/PageDown by a viewport, Home/End
680
+ * to the ends, Space and Shift+Space as a second pair of page keys
681
+ * because that is what a reader's hand is already on.
682
+ *
683
+ * Runs after the application's own `onKeyDown`, and not at all if that
684
+ * called `preventDefault` — the same contract `<textinput>` editing has.
685
+ */
686
+ defaultKeyDown(ev) {
687
+ // nothing to scroll, nothing to swallow: a plain box must leave the
688
+ // arrows and Page keys to whatever else would answer them
689
+ if (!this._scrollsWithKeys()) return super.defaultKeyDown(ev);
690
+ const step = SCROLL_KEY_STEP * this.scale;
691
+ const page = Math.max(
692
+ 1,
693
+ this.abs.height - SCROLL_KEY_PAGE_OVERLAP * this.scale,
694
+ );
695
+ // Left and Right are the directions on the *screen*, and `scrollX` runs
696
+ // from the start of the content — so which of them moves it forward
697
+ // depends on which way the content runs. Home/End and the Page keys
698
+ // need no such rule: they already name the logical ends.
699
+ const forward = this.direction === 'rtl' ? -step : step;
700
+ switch (ev.keysym) {
701
+ case XK_DOWN:
702
+ return this._scrollByDevice(0, step);
703
+ case XK_UP:
704
+ return this._scrollByDevice(0, -step);
705
+ case XK_RIGHT:
706
+ return this._scrollByDevice(forward, 0);
707
+ case XK_LEFT:
708
+ return this._scrollByDevice(-forward, 0);
709
+ case XK_PAGE_DOWN:
710
+ return this._scrollByDevice(0, page);
711
+ case XK_PAGE_UP:
712
+ return this._scrollByDevice(0, -page);
713
+ case XK_HOME:
714
+ return this._scrollToDevice({ y: 0 });
715
+ case XK_END:
716
+ // the end the pass measures, if one is owed (`_holdScrollTo`)
717
+ return this._scrollToDevice({ y: Infinity });
718
+ case XK_SPACE:
719
+ return this._scrollByDevice(0, ev.shiftKey ? -page : page);
720
+ default:
721
+ return super.defaultKeyDown(ev);
722
+ }
723
+ }
724
+
725
+ /**
726
+ * Scroll the minimum amount that brings a descendant fully into view.
727
+ * The request is queued rather than applied immediately: absolute rects
728
+ * only exist after a layout pass, so a caller reacting to a mount (a
729
+ * list widget moving its selection, say) would otherwise measure a node
730
+ * that has no geometry yet. `absolutize` resolves it against freshly
731
+ * computed yoga positions, and `onScroll` reports the move once that
732
+ * pass is over (`_reportScrollTo`).
733
+ */
734
+ scrollIntoView(node) {
735
+ if (!node || !this.isScroller()) return;
736
+ this._scrollIntoViewTarget = node;
737
+ // whatever the resolved scroll moves is inside this clipped viewport,
738
+ // so the viewport's own before/after rects bound the frame
739
+ this._invalidateLayout('scroll');
740
+ }
741
+
742
+ _resolveScrollIntoView() {
743
+ const target = this._scrollIntoViewTarget;
744
+ if (!target) return;
745
+ this._scrollIntoViewTarget = null;
746
+ if (target.destroyed || !target.yoga) return;
747
+ // offset of the target within our content box, summed up the chain so
748
+ // targets nested below a direct child work too
749
+ let top = 0;
750
+ let left = 0;
751
+ for (let n = target; n && n !== this; n = n.parent) {
752
+ if (!n.yoga) return; // not (or no longer) inside this viewport
753
+ const offset = offsetInParent(n);
754
+ top += offset.y;
755
+ left += offset.x;
756
+ if (!n.parent) return;
757
+ }
758
+ const bottom = top + target.yoga.getComputedHeight();
759
+ // Horizontally the two edges are measured from the content's **start**,
760
+ // the same units `scrollX` is in — so under RTL the target's near edge
761
+ // is its right one and both are counted back from the viewport's width.
762
+ const w = target.yoga.getComputedWidth();
763
+ const near =
764
+ this.direction === 'rtl'
765
+ ? this.yoga.getComputedWidth() - left - w
766
+ : left;
767
+ const far = near + w;
768
+ if (bottom > this.scrollY + this.abs.height) {
769
+ this.scrollY = bottom - this.abs.height;
770
+ }
771
+ if (top < this.scrollY) this.scrollY = top;
772
+ if (far > this.scrollX + this.abs.width) {
773
+ this.scrollX = far - this.abs.width;
774
+ }
775
+ if (near < this.scrollX) this.scrollX = near;
776
+ }
777
+
778
+ paint(ctx) {
779
+ super.paint(ctx);
780
+ this._paintScrollbars(ctx);
781
+ }
782
+
783
+ /** Over the content and outside the clip — a `<window>` reaches this by
784
+ * its own route, since it paints through `_paintRegion` and never
785
+ * through `Node.paint`. */
786
+ _paintScrollbars(ctx) {
787
+ for (const bar of this._scrollbars()) {
788
+ paintScrollbarThumb(ctx, bar, this.props.scrollbarColor);
789
+ }
790
+ }
791
+
792
+ /** null when this is not a scroll container, when the bar is switched
793
+ * off, or when there is nothing to scroll on that axis. */
794
+ _scrollbar(axis = 'y') {
795
+ if (!this.isScroller() || this.props.scrollbar === false) return null;
796
+ const horizontal = axis === 'x';
797
+ // when both bars show, each stops short of the other's corner
798
+ const other = horizontal
799
+ ? this.contentHeight > this.abs.height
800
+ : this.contentWidth > this.abs.width;
801
+ return scrollbarGeometry({
802
+ axis,
803
+ start: horizontal ? this.abs.x : this.abs.y,
804
+ viewport: horizontal ? this.abs.width : this.abs.height,
805
+ content: horizontal ? this.contentWidth : this.contentHeight,
806
+ across: horizontal ? this.abs.y : this.abs.x,
807
+ crossSize: horizontal ? this.abs.height : this.abs.width,
808
+ scroll: horizontal ? this.scrollX : this.scrollY,
809
+ inset: 2 * this.scale,
810
+ shorten: other ? (SCROLLBAR_WIDTH + 2) * this.scale : 0,
811
+ direction: this.direction,
812
+ scale: this.scale,
813
+ });
814
+ }
815
+
816
+ _scrollbars() {
817
+ if (!this.isScroller()) return EMPTY_SCROLLBARS;
818
+ return [this._scrollbar('y'), this._scrollbar('x')].filter(Boolean);
819
+ }
820
+
821
+ /**
822
+ * The bar belongs to the scroller, not to the content under it — the same
823
+ * rule a browser applies. Without this a press on the thumb would be
824
+ * delivered to whatever child happens to be painted beneath it.
825
+ */
826
+ hitTest(x, y) {
827
+ if (this.isScroller()) {
828
+ for (const bar of this._scrollbars()) {
829
+ if (scrollbarHit(bar, x, y)) return this;
830
+ }
831
+ }
832
+ return super.hitTest(x, y);
833
+ }
834
+
835
+ defaultMouseDown(ev) {
836
+ // bar geometry is device pixels; the synthetic event is logical, so
837
+ // the hit tests here read the native coordinates
838
+ const nx = ev.nativeEvent?.x ?? ev.x * this.scale;
839
+ const ny = ev.nativeEvent?.y ?? ev.y * this.scale;
840
+ for (const bar of this._scrollbars()) {
841
+ const hit = scrollbarHit(bar, nx, ny);
842
+ if (!hit) continue;
843
+ const at = along(bar, nx, ny);
844
+ if (hit === 'thumb') {
845
+ // remember where in the thumb it was grabbed, so it does not jump
846
+ this._barGrab = { axis: bar.axis, offset: at - bar.thumbStart };
847
+ ev.capturePointer();
848
+ return;
849
+ }
850
+ // a press on the track pages towards it, like PageUp/PageDown — and
851
+ // "towards it" is a visual direction, so it flips with the bar
852
+ const page = bar.axis === 'x' ? this.abs.width : this.abs.height;
853
+ const back = at < bar.thumbStart ? !bar.reversed : bar.reversed;
854
+ const delta = back ? -page : page;
855
+ if (bar.axis === 'x') this._scrollByDevice(delta, 0);
856
+ else this._scrollByDevice(0, delta);
857
+ return;
858
+ }
859
+ // no bar under the press: it belongs to whatever is behind the bars,
860
+ // which for a `selectable` pane is the selection (issue #259)
861
+ super.defaultMouseDown(ev);
862
+ }
863
+
864
+ defaultMouseDrag(ev) {
865
+ if (this._barGrab == null) return super.defaultMouseDrag(ev);
866
+ const bar = this._scrollbar(this._barGrab.axis);
867
+ if (!bar || bar.travel <= 0) return;
868
+ const nx = ev.nativeEvent?.x ?? ev.x * this.scale;
869
+ const ny = ev.nativeEvent?.y ?? ev.y * this.scale;
870
+ const at = along(bar, nx, ny) - this._barGrab.offset - bar.trackStart;
871
+ const from = bar.reversed ? bar.travel - at : at;
872
+ const to = (from / bar.travel) * bar.range;
873
+ this._scrollToDevice(bar.axis === 'x' ? { x: to } : { y: to });
874
+ }
875
+
876
+ defaultMouseUp(ev) {
877
+ if (this._barGrab != null) {
878
+ this._barGrab = null;
879
+ return;
880
+ }
881
+ super.defaultMouseUp(ev);
882
+ }
883
+ };
884
+
885
+ // An arrow key scrolls by a wheel notch — literally the one events.js
886
+ // converts a notch into, so the two input routes agree about what one step
887
+ // is however far a notch turns out to be.
888
+ const SCROLL_KEY_STEP = WHEEL_NOTCH_PX;
889
+ // A page keeps a sliver of the previous one on screen, so the eye has
890
+ // somewhere to land. Toolkits all keep a line or two; this is about that.
891
+ const SCROLL_KEY_PAGE_OVERLAP = 24;