react-x11 2.44.1 → 2.45.1
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.
- package/package.json +1 -1
- package/src/cocoa/app.js +6 -0
- package/src/cocoa/glarea.js +18 -2
- package/src/cocoa/panewindow.js +16 -0
- package/src/cocoa/window.js +6 -0
- package/src/foreignnodes.js +9 -6
- package/src/frame/index.js +16 -0
- package/src/glnodes.js +12 -2
- package/src/node.d.ts +14 -0
- package/src/nodes/invalidate.js +30 -0
- package/src/nodes/node.js +19 -0
- package/src/nodes/scrollable.js +49 -0
- package/src/nodes/window/floors.js +20 -5
- package/src/nodes/window/size.js +32 -7
- package/src/testing/harness.js +25 -7
- package/src/testing/index.d.ts +17 -1
- package/src/testing/queries.js +7 -18
package/package.json
CHANGED
package/src/cocoa/app.js
CHANGED
|
@@ -1014,8 +1014,11 @@ export class CocoaApp {
|
|
|
1014
1014
|
wnd?._shown?.(msg.seq);
|
|
1015
1015
|
} else if (msg?.type === 'pane-rect') {
|
|
1016
1016
|
pane.setPaneSize(msg.width, msg.height, msg.scale, msg.screen);
|
|
1017
|
+
if (typeof msg.live === 'boolean') pane.setLive(msg.live);
|
|
1017
1018
|
this._flushPaneOutbox();
|
|
1018
1019
|
this._afterInput();
|
|
1020
|
+
} else if (msg?.type === 'pane-live') {
|
|
1021
|
+
pane.setLive(msg.live === true);
|
|
1019
1022
|
} else if (msg?.type === 'pane-menu-answer') {
|
|
1020
1023
|
const answer = this._paneMenus?.get(msg.menu);
|
|
1021
1024
|
this._paneMenus?.delete(msg.menu);
|
|
@@ -1687,6 +1690,9 @@ export class CocoaApp {
|
|
|
1687
1690
|
const wnd = this._window(ev);
|
|
1688
1691
|
if (!wnd || wnd.destroyed) return;
|
|
1689
1692
|
wnd.liveResizing = ev.phase === 'begin';
|
|
1693
|
+
// and what lives in the window without being it hears: a `<Frame>`'s
|
|
1694
|
+
// pane, which has no window of its own to be resized live
|
|
1695
|
+
wnd.emit('liveresize', { live: wnd.liveResizing });
|
|
1690
1696
|
}
|
|
1691
1697
|
|
|
1692
1698
|
/**
|
package/src/cocoa/glarea.js
CHANGED
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
// What GlAreaNode needs from us is the child-"window" contract it already
|
|
12
12
|
// speaks (src/glnodes.js): `createWindow({ parent, … })` answering an
|
|
13
13
|
// object with `getContext('opengl', config)`, `setState(rect)`, `map()`,
|
|
14
|
-
// `destroy()`, `requestAnimationFrame`. On X11 that child is a real X
|
|
14
|
+
// `unmap()`, `destroy()`, `requestAnimationFrame`. On X11 that child is a real X
|
|
15
15
|
// window stacked above the parent's drawing; here it is a sublayer of the
|
|
16
16
|
// window's root layer with a high zPosition — the same "GL sits above the
|
|
17
17
|
// 2D" semantics, by the same mechanism the platform gives us. The layer
|
|
@@ -153,6 +153,9 @@ export class CocoaGLArea {
|
|
|
153
153
|
return layer;
|
|
154
154
|
});
|
|
155
155
|
this.rect = null;
|
|
156
|
+
// Ours to keep, because every `setState` writes the layer's whole prop
|
|
157
|
+
// set: a geometry change on a hidden surface must not show it again.
|
|
158
|
+
this._hidden = false;
|
|
156
159
|
this.setState({
|
|
157
160
|
x: options.x ?? 0,
|
|
158
161
|
y: options.y ?? 0,
|
|
@@ -178,7 +181,7 @@ export class CocoaGLArea {
|
|
|
178
181
|
// mirroring the layer is the flip, applied where the compositor is
|
|
179
182
|
// already transforming instead of in anyone's shader.
|
|
180
183
|
transform: { scaleY: -1 },
|
|
181
|
-
hidden:
|
|
184
|
+
hidden: this._hidden,
|
|
182
185
|
});
|
|
183
186
|
this._context?._resized(rect.width, rect.height);
|
|
184
187
|
}
|
|
@@ -200,9 +203,22 @@ export class CocoaGLArea {
|
|
|
200
203
|
}
|
|
201
204
|
|
|
202
205
|
map() {
|
|
206
|
+
this._hidden = false;
|
|
203
207
|
if (!this.destroyed) this._setLayerProps({ hidden: false });
|
|
204
208
|
}
|
|
205
209
|
|
|
210
|
+
/**
|
|
211
|
+
* The other half of `map()`, which this surface went without: a layer is
|
|
212
|
+
* composited by the WindowServer rather than painted by the window, so a
|
|
213
|
+
* subtree going off the screen leaves it on screen over what replaced it.
|
|
214
|
+
* X11 and Wayland unmap the surface's window for this; here the layer's
|
|
215
|
+
* own `hidden` carries it.
|
|
216
|
+
*/
|
|
217
|
+
unmap() {
|
|
218
|
+
this._hidden = true;
|
|
219
|
+
if (!this.destroyed) this._setLayerProps({ hidden: true });
|
|
220
|
+
}
|
|
221
|
+
|
|
206
222
|
/**
|
|
207
223
|
* Everything this layer is handed goes out with Core Animation's implicit
|
|
208
224
|
* actions off. The layer is ours, not a presenter's, so no frame's
|
package/src/cocoa/panewindow.js
CHANGED
|
@@ -50,6 +50,8 @@ export class CocoaPaneWindow {
|
|
|
50
50
|
// polls it through the pane's ref, so both spellings answer
|
|
51
51
|
this.id = this.windowId;
|
|
52
52
|
this.windowNumber = this.windowId;
|
|
53
|
+
// the host's word, which the pane has no AppKit to hear (`setLive`)
|
|
54
|
+
this.liveResizing = false;
|
|
53
55
|
this.scale = app.scale ?? 2;
|
|
54
56
|
// Device pixels, as every window's attributes are (`windowAttributes`
|
|
55
57
|
// multiplies the props through once): the size the pane is born at,
|
|
@@ -112,6 +114,20 @@ export class CocoaPaneWindow {
|
|
|
112
114
|
* a host that does not say. A move alone is a `resize` that `moved`, the
|
|
113
115
|
* event a window's own move is, so what is anchored here follows it.
|
|
114
116
|
*/
|
|
117
|
+
/**
|
|
118
|
+
* Whether the window the pane is in is being resized live, as the host
|
|
119
|
+
* says (`pane-rect`, `pane-live`, src/frame/index.js): what a window's
|
|
120
|
+
* `liveResizing` is in a process of its own, where AppKit brackets the
|
|
121
|
+
* drag of its edge. Core defers a content floor's measurement while it
|
|
122
|
+
* is (src/nodes/window/size.js), and catches it up the frame after it
|
|
123
|
+
* ends; and it is announced as a window announces its own.
|
|
124
|
+
*/
|
|
125
|
+
setLive(live) {
|
|
126
|
+
if (this.destroyed || this.liveResizing === live) return;
|
|
127
|
+
this.liveResizing = live;
|
|
128
|
+
this.emit('liveresize', { live });
|
|
129
|
+
}
|
|
130
|
+
|
|
115
131
|
setPaneSize(width, height, scale, screen) {
|
|
116
132
|
if (this.destroyed) return;
|
|
117
133
|
if (scale) this.scale = scale;
|
package/src/cocoa/window.js
CHANGED
|
@@ -278,6 +278,12 @@ export class CocoaWindow {
|
|
|
278
278
|
let list = this._handlers.get(name);
|
|
279
279
|
if (!list) this._handlers.set(name, (list = []));
|
|
280
280
|
list.push(fn);
|
|
281
|
+
// and the way to stop listening, for a listener that goes before the
|
|
282
|
+
// window does (`<Frame>`'s, for `liveresize`)
|
|
283
|
+
return () => {
|
|
284
|
+
const at = list.indexOf(fn);
|
|
285
|
+
if (at >= 0) list.splice(at, 1);
|
|
286
|
+
};
|
|
281
287
|
}
|
|
282
288
|
|
|
283
289
|
emit(name, ev) {
|
package/src/foreignnodes.js
CHANGED
|
@@ -488,18 +488,21 @@ export class ForeignNode extends Node {
|
|
|
488
488
|
wnd.setBackgroundPixel(pixel);
|
|
489
489
|
}
|
|
490
490
|
|
|
491
|
-
|
|
492
|
-
|
|
491
|
+
/** The client's window is a window of the display system over this one,
|
|
492
|
+
* so it follows the whole subtree's visibility — see
|
|
493
|
+
* `GlAreaNode._syncSurfaceVisibility`, which answers the same call. */
|
|
494
|
+
_syncSurfaceVisibility() {
|
|
493
495
|
this._syncMapped();
|
|
494
496
|
}
|
|
495
497
|
|
|
496
|
-
/** The container follows
|
|
497
|
-
*
|
|
498
|
-
*
|
|
498
|
+
/** The container follows whether this subtree is on screen — this node's
|
|
499
|
+
* own `hidden` flag, or anything above it that took the subtree away; the
|
|
500
|
+
* client follows the container, because an inferior of an unmapped window
|
|
501
|
+
* is not viewable however mapped it is itself. */
|
|
499
502
|
_syncMapped() {
|
|
500
503
|
const wnd = this.socket?.window;
|
|
501
504
|
if (!wnd) return;
|
|
502
|
-
if (this.
|
|
505
|
+
if (this._hiddenInTree()) wnd.unmap?.();
|
|
503
506
|
else wnd.map?.();
|
|
504
507
|
}
|
|
505
508
|
|
package/src/frame/index.js
CHANGED
|
@@ -574,6 +574,20 @@ function PaneHostView({
|
|
|
574
574
|
let acks = false;
|
|
575
575
|
let inFlight = false;
|
|
576
576
|
let waiting = null;
|
|
577
|
+
// And whether the window is being resized live — AppKit's drag of its
|
|
578
|
+
// edge, from its begin to its end (`liveResizing`) — which the pane
|
|
579
|
+
// has no window to hear. Core defers measuring a content floor for a
|
|
580
|
+
// drag's length and catches it up after it (src/nodes/window/size.js),
|
|
581
|
+
// and an element puts off what it would rather do once the drag ends:
|
|
582
|
+
// a pane measured its floors at every step, a document a pixel wide
|
|
583
|
+
// among them, and laid a document out whole at every rest of the drag.
|
|
584
|
+
// With every rect, and on its own where only that changed — the end
|
|
585
|
+
// of a drag comes after its last size.
|
|
586
|
+
const hostWindow = node.root?.window;
|
|
587
|
+
const liveNow = () => hostWindow?.liveResizing === true;
|
|
588
|
+
const offLive = hostWindow?.on?.('liveresize', (ev) =>
|
|
589
|
+
s.trySend?.({ type: 'pane-live', live: ev?.live === true }),
|
|
590
|
+
);
|
|
577
591
|
const transmit = (rect) => {
|
|
578
592
|
sent = rect;
|
|
579
593
|
s.trySend?.({
|
|
@@ -581,6 +595,7 @@ function PaneHostView({
|
|
|
581
595
|
width: rect.width,
|
|
582
596
|
height: rect.height,
|
|
583
597
|
scale: rect.scale,
|
|
598
|
+
live: liveNow(),
|
|
584
599
|
...(rect.screen && { screen: rect.screen }),
|
|
585
600
|
});
|
|
586
601
|
if (!acks) return;
|
|
@@ -685,6 +700,7 @@ function PaneHostView({
|
|
|
685
700
|
waiting = null;
|
|
686
701
|
offAnchor?.();
|
|
687
702
|
offFocus?.();
|
|
703
|
+
if (typeof offLive === 'function') offLive();
|
|
688
704
|
node.defaultCursor = undefined;
|
|
689
705
|
node.root?.events?.refreshCursor();
|
|
690
706
|
node.absolutize = origAbsolutize;
|
package/src/glnodes.js
CHANGED
|
@@ -324,6 +324,9 @@ export class GlAreaNode extends Node {
|
|
|
324
324
|
// same zPosition. The window's hit test reads the list in that order
|
|
325
325
|
// (`EventManager._surfaceAt`).
|
|
326
326
|
this._joinSurfaces();
|
|
327
|
+
// A surface created inside a subtree that is already hidden — a tab
|
|
328
|
+
// mounted behind the one on screen — must not come up over it.
|
|
329
|
+
this._syncSurfaceVisibility();
|
|
327
330
|
this.gl = wnd.getContext('opengl', config);
|
|
328
331
|
// A buffer freed by the display is a frame that can be drawn again — for
|
|
329
332
|
// a frame that was waiting for one. The Cocoa surface and the CGL
|
|
@@ -725,8 +728,15 @@ export class GlAreaNode extends Node {
|
|
|
725
728
|
this.requestFrame();
|
|
726
729
|
}
|
|
727
730
|
|
|
728
|
-
|
|
729
|
-
|
|
731
|
+
/**
|
|
732
|
+
* The surface is a window of the display system, so it goes when the
|
|
733
|
+
* subtree does — `display: 'none'` anywhere above it, or React's own
|
|
734
|
+
* `hidden` for `<Activity>`/`<Suspense>`, on this node or on an ancestor.
|
|
735
|
+
* `Node._syncSurfaces` calls this for every route; the panes the children
|
|
736
|
+
* are drawn on follow the same answer.
|
|
737
|
+
*/
|
|
738
|
+
_syncSurfaceVisibility() {
|
|
739
|
+
const hidden = this._hiddenInTree();
|
|
730
740
|
if (hidden) this.window?.unmap?.();
|
|
731
741
|
else this.window?.map?.();
|
|
732
742
|
this._overlay?.setHidden(hidden);
|
package/src/node.d.ts
CHANGED
|
@@ -750,6 +750,20 @@ export declare class Node {
|
|
|
750
750
|
* each scroll the pane blits, after layout.
|
|
751
751
|
*/
|
|
752
752
|
viewportFixedRects(): Rect[] | null;
|
|
753
|
+
/**
|
|
754
|
+
* What this element draws under the viewport of the scroll pane it is in
|
|
755
|
+
* moved by (dx, dy) device pixels, and what the viewport shows is to stay
|
|
756
|
+
* where it is on screen: the pane's offsets move by as much, in the
|
|
757
|
+
* layout pass running now, after it measures what it holds and before it
|
|
758
|
+
* places it (CSS Scroll Anchoring 1) — a document laid out again at
|
|
759
|
+
* another width, whose text above the fold broke into more lines or
|
|
760
|
+
* fewer. Asked of the nearest scroll pane; shifts in one pass add up.
|
|
761
|
+
* Ask from the layout pass (`measureContent`); asked outside one, the
|
|
762
|
+
* shift waits for the next and asks for it. A `scrollTo` made since the
|
|
763
|
+
* last pass drops it, a `scrollIntoView` lands after it, and the result
|
|
764
|
+
* is clamped as any scroll. Returns whether a pane took it.
|
|
765
|
+
*/
|
|
766
|
+
anchorScrollBy(dx: number, dy: number): boolean;
|
|
753
767
|
/**
|
|
754
768
|
* How far this element's own drawing reaches past its box, in device
|
|
755
769
|
* pixels on every side — 0, the default, for an element that stays inside
|
package/src/nodes/invalidate.js
CHANGED
|
@@ -137,6 +137,36 @@ export class NodeInvalidate {
|
|
|
137
137
|
return null;
|
|
138
138
|
}
|
|
139
139
|
|
|
140
|
+
/**
|
|
141
|
+
* What this element draws under the viewport of the scroll pane it is
|
|
142
|
+
* in moved by (dx, dy) device pixels, and what the viewport shows is to
|
|
143
|
+
* stay where it is on screen: the pane's offsets move by as much, in the
|
|
144
|
+
* layout pass running now, after it measures what it holds and before it
|
|
145
|
+
* places it (CSS Scroll Anchoring 1). A document laid out again at
|
|
146
|
+
* another width is the case: its text above the fold breaks into more
|
|
147
|
+
* lines or fewer, and the paragraph at the top of the viewport moves
|
|
148
|
+
* down or up the document by the difference — which, unanswered, scrolls
|
|
149
|
+
* the reader a page away from where they were.
|
|
150
|
+
*
|
|
151
|
+
* Asked of the nearest scroll pane, the one whose viewport the element
|
|
152
|
+
* is seen through; shifts asked of it in one pass add up. Ask from the
|
|
153
|
+
* layout pass — `measureContent`, or what it calls — where the pane
|
|
154
|
+
* places its content after; asked outside one, the shift waits for the
|
|
155
|
+
* next pass, and asks for it. A `scrollTo` the application made since
|
|
156
|
+
* the last pass drops it, since the shift was measured against the
|
|
157
|
+
* offsets before that scroll; a `scrollIntoView` lands after it; and the
|
|
158
|
+
* pane clamps the result to what it holds, as any scroll. Returns
|
|
159
|
+
* whether a pane took it: false where nothing scrolls the element.
|
|
160
|
+
*/
|
|
161
|
+
anchorScrollBy(dx, dy) {
|
|
162
|
+
for (let n = this.parent; n; n = n.parent) {
|
|
163
|
+
if (typeof n._anchorScrollBy === 'function' && n.isScroller()) {
|
|
164
|
+
return n._anchorScrollBy(dx, dy);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return false;
|
|
168
|
+
}
|
|
169
|
+
|
|
140
170
|
/**
|
|
141
171
|
* How far this element's own drawing reaches past its box — device
|
|
142
172
|
* pixels, on every side — or 0, the default, for one that stays inside
|
package/src/nodes/node.js
CHANGED
|
@@ -860,12 +860,31 @@ export class Node {
|
|
|
860
860
|
// window rather than for this subtree — the set is the handful of nodes
|
|
861
861
|
// that declare an `animation`, and each one answers for itself.
|
|
862
862
|
if (this.root?._loopNodes?.size) this.root._refreshLoops();
|
|
863
|
+
// Same funnel, same reason, one layer down: a surface — a `<glarea>`'s GL
|
|
864
|
+
// child, an embedded client's window — is a real window of the display
|
|
865
|
+
// system stacked above everything this window paints, not pixels in its
|
|
866
|
+
// 2d context. Hiding a subtree takes away its layout and its paint, and
|
|
867
|
+
// reaches a surface inside it through neither: it would stay on screen,
|
|
868
|
+
// over whatever is shown in its place, which is what a tab that keeps its
|
|
869
|
+
// screens mounted does every time it switches.
|
|
870
|
+
this._syncSurfaces();
|
|
863
871
|
const events = this._focusManager();
|
|
864
872
|
if (!events) return;
|
|
865
873
|
if (visible) events.subtreeRevealed(this);
|
|
866
874
|
else events.subtreeHidden(this);
|
|
867
875
|
}
|
|
868
876
|
|
|
877
|
+
/**
|
|
878
|
+
* Ask every surface in this subtree — this node included — whether it is
|
|
879
|
+
* still on screen, and map or unmap it to match. Walked rather than kept
|
|
880
|
+
* in a registry because the subtree whose visibility just changed is
|
|
881
|
+
* exactly the scope, and all but two elements answer nothing.
|
|
882
|
+
*/
|
|
883
|
+
_syncSurfaces() {
|
|
884
|
+
this._syncSurfaceVisibility?.();
|
|
885
|
+
for (const child of this.children) child._syncSurfaces?.();
|
|
886
|
+
}
|
|
887
|
+
|
|
869
888
|
/**
|
|
870
889
|
* Focus this node, as clicking it would: the owning window's focus moves
|
|
871
890
|
* here, `onBlur` fires on whatever had it, `onFocus` here. Also pulls the
|
package/src/nodes/scrollable.js
CHANGED
|
@@ -330,6 +330,10 @@ export const Scrollable = (Base) =>
|
|
|
330
330
|
// the viewport grew, under it. A browser fires `scroll` for each, so
|
|
331
331
|
// `onScroll` hears of them too, once the pass is over.
|
|
332
332
|
const from = { x: this.scrollX, y: this.scrollY };
|
|
333
|
+
// What the content asked to keep in place lands before them all
|
|
334
|
+
// (`anchorScrollBy`): it moved under the viewport in this pass, and
|
|
335
|
+
// a scroll anyone asks for is from where it shows what it did
|
|
336
|
+
this._applyAnchorShift();
|
|
333
337
|
// A scrollTo held for this pass lands first, so a node asked into view
|
|
334
338
|
// in the same frame is brought in from where that scroll put the pane:
|
|
335
339
|
// the order the two have on a laid-out pane, where scrollTo applies at
|
|
@@ -607,6 +611,10 @@ export const Scrollable = (Base) =>
|
|
|
607
611
|
}
|
|
608
612
|
this.scrollX = next.x;
|
|
609
613
|
this.scrollY = next.y;
|
|
614
|
+
// a scroll asked for since the last pass is where the pane goes: a
|
|
615
|
+
// shift the content asks for in the next was measured against the
|
|
616
|
+
// offsets before it (`_applyAnchorShift`)
|
|
617
|
+
this._scrolledSincePass = true;
|
|
610
618
|
if (moved) this.props.onScroll?.(this._scrollEvent());
|
|
611
619
|
// A scroll reflows this viewport's contents and nothing else, and the
|
|
612
620
|
// viewport clips them, so the damage is this node's own rect. It is a
|
|
@@ -707,6 +715,47 @@ export const Scrollable = (Base) =>
|
|
|
707
715
|
return true;
|
|
708
716
|
}
|
|
709
717
|
|
|
718
|
+
/**
|
|
719
|
+
* The pane's half of `Node.anchorScrollBy`: a shift of the offsets the
|
|
720
|
+
* content asked for, added up until the pass applies it
|
|
721
|
+
* (`_applyAnchorShift`). The pass measures the content again, since
|
|
722
|
+
* what moved under the viewport moved by being laid out; one asked
|
|
723
|
+
* outside a pass asks for one.
|
|
724
|
+
*/
|
|
725
|
+
_anchorScrollBy(dx, dy) {
|
|
726
|
+
if (!Number.isFinite(dx) || !Number.isFinite(dy)) return false;
|
|
727
|
+
if (dx === 0 && dy === 0) return true;
|
|
728
|
+
const shift = (this._anchorShift ??= { x: 0, y: 0 });
|
|
729
|
+
shift.x += dx;
|
|
730
|
+
shift.y += dy;
|
|
731
|
+
this._scrollMeasureDirty = true;
|
|
732
|
+
const root = this.root;
|
|
733
|
+
if (root && !root._inFlush) root.invalidate(true, this, 'scroll');
|
|
734
|
+
return true;
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* Apply the shift the content asked for (`_anchorScrollBy`), before
|
|
739
|
+
* anything else the pass moves the offsets by: on each axis it scrolls,
|
|
740
|
+
* the clamp after takes it into the extent just measured. Its
|
|
741
|
+
* `onScroll` and its claim of the viewport are the pass's, as for a
|
|
742
|
+
* held `scrollTo`. A scroll the application asked for since the last
|
|
743
|
+
* pass drops it: the shift was measured against the offsets before
|
|
744
|
+
* that scroll, which is where the application wants the pane now.
|
|
745
|
+
*/
|
|
746
|
+
_applyAnchorShift() {
|
|
747
|
+
const shift = this._anchorShift;
|
|
748
|
+
const scrolled = this._scrolledSincePass === true;
|
|
749
|
+
this._scrolledSincePass = false;
|
|
750
|
+
if (!shift) return;
|
|
751
|
+
this._anchorShift = null;
|
|
752
|
+
if (scrolled) return;
|
|
753
|
+
// `scrollX` is a distance from the start in either direction, and
|
|
754
|
+
// content that moved towards the end is followed the same way
|
|
755
|
+
this.scrollX += this.direction === 'rtl' ? -shift.x : shift.x;
|
|
756
|
+
this.scrollY += shift.y;
|
|
757
|
+
}
|
|
758
|
+
|
|
710
759
|
/**
|
|
711
760
|
* Answer a held request (`_holdScrollTo`) against the extent this pass
|
|
712
761
|
* has just measured: each held axis's base, clamped, then each step
|
|
@@ -533,10 +533,12 @@ export function setMeasuringShrink(node, axis, out) {
|
|
|
533
533
|
* It is still laid out, though, and a leaf's answer could reach something
|
|
534
534
|
* that is read through its size. So it is marked only where it cannot:
|
|
535
535
|
*
|
|
536
|
-
* - every box from
|
|
537
|
-
* its content — the
|
|
538
|
-
*
|
|
539
|
-
*
|
|
536
|
+
* - every box from `node` down to it is as wide as something other than
|
|
537
|
+
* its content — `node` the width the pass gives it (no room, for the
|
|
538
|
+
* window; its column's, for a spine root; its own, for a box that sizes
|
|
539
|
+
* itself), and below it a width it names or a column stretching it — so
|
|
540
|
+
* the width the leaf answers is one yoga replaces, and nothing above it
|
|
541
|
+
* is sized from it;
|
|
540
542
|
* - no box is handed a share of space the leaf's height changed, which is
|
|
541
543
|
* how a height could become somebody's width — an aspect ratio, a column
|
|
542
544
|
* that wraps, an image sized to the height it is given. Every column from
|
|
@@ -656,15 +658,28 @@ function stretchedAlong(node, axis) {
|
|
|
656
658
|
* names are numbers — a height, a basis (a height, down a column), a floor or
|
|
657
659
|
* a ceiling; and `'unknown'` for a share of something (a percentage), which
|
|
658
660
|
* a pass with no height on offer resolves as it likes.
|
|
661
|
+
*
|
|
662
|
+
* A floor of nothing bounds nothing: a box as tall as its content is at
|
|
663
|
+
* least that tall already, so its height is its content's. It is how a box
|
|
664
|
+
* says it may shrink, and a browser's page has one on every box from the
|
|
665
|
+
* window down to its scroller, so that a page taller than the window
|
|
666
|
+
* scrolls. Read as a height of the box's own, it stopped the walk at the
|
|
667
|
+
* first of them, where the scroller gives way: the whole document in the
|
|
668
|
+
* scroller was set a word to a line each time the tree was measured, 100
|
|
669
|
+
* to 250 ms of a frame for a long Wikipedia article, and nothing read any
|
|
670
|
+
* of it. And it took the box pinned to both edges of such a column for one
|
|
671
|
+
* whose height nothing in flow decides, which it is.
|
|
659
672
|
*/
|
|
660
673
|
function heightOf(yoga) {
|
|
674
|
+
const floor = yoga.getMinHeight();
|
|
661
675
|
let own = false;
|
|
662
676
|
for (const value of [
|
|
663
677
|
yoga.getHeight(),
|
|
664
678
|
yoga.getFlexBasis(),
|
|
665
|
-
|
|
679
|
+
floor.value === 0 ? null : floor,
|
|
666
680
|
yoga.getMaxHeight(),
|
|
667
681
|
]) {
|
|
682
|
+
if (value === null) continue;
|
|
668
683
|
if (value.unit === Yoga.UNIT_PERCENT) return 'unknown';
|
|
669
684
|
if (value.unit === Yoga.UNIT_POINT) own = true;
|
|
670
685
|
}
|
package/src/nodes/window/size.js
CHANGED
|
@@ -354,12 +354,28 @@ export class WindowSize {
|
|
|
354
354
|
if (axis === 'width') {
|
|
355
355
|
const shrunk = [];
|
|
356
356
|
setMeasuringShrink(root, axis, shrunk);
|
|
357
|
+
// Nothing reads a root's own width — only heights travel up a spine
|
|
358
|
+
// (spine.js) — so what it holds is read only where a floor is written
|
|
359
|
+
// from it, as down the window's columns, and a leaf that is the root
|
|
360
|
+
// is laid out at the width its column offers, which is no answer of
|
|
361
|
+
// its own: a document in a scroller is a root when it changes.
|
|
362
|
+
const read = NO_UNREAD_WIDTHS || this._readEveryWidth;
|
|
363
|
+
const unread = [];
|
|
364
|
+
if (!read && root._measureFn) {
|
|
365
|
+
root._widthUnread = true;
|
|
366
|
+
unread.push(root);
|
|
367
|
+
} else if (!read) {
|
|
368
|
+
markUnreadLeaves(root, unread);
|
|
369
|
+
}
|
|
357
370
|
this._scopedFloorPasses += 1;
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
371
|
+
try {
|
|
372
|
+
yoga.calculateLayout(widths.widthPass, undefined, dir);
|
|
373
|
+
} finally {
|
|
374
|
+
for (const leaf of unread) leaf._widthUnread = false;
|
|
375
|
+
}
|
|
376
|
+
const span = contentSpan(root, axis, null, this, read);
|
|
377
|
+
const own = declaresOwnMinimum(root, axis);
|
|
378
|
+
if (own || read) root._floorW = own ? yoga.getComputedWidth() : span;
|
|
363
379
|
restoreShrink(shrunk);
|
|
364
380
|
return;
|
|
365
381
|
}
|
|
@@ -443,9 +459,18 @@ export class WindowSize {
|
|
|
443
459
|
if (axis === 'width') {
|
|
444
460
|
const shrunk = [];
|
|
445
461
|
setMeasuringShrink(boundary, axis, shrunk);
|
|
462
|
+
// its own extent is its style's, so what it holds is read only where
|
|
463
|
+
// a floor is written from it, as down the window's columns
|
|
464
|
+
const read = NO_UNREAD_WIDTHS || this._readEveryWidth;
|
|
465
|
+
const unread = [];
|
|
466
|
+
if (!read) markUnreadLeaves(boundary, unread);
|
|
446
467
|
this._scopedFloorPasses += 1;
|
|
447
|
-
|
|
448
|
-
|
|
468
|
+
try {
|
|
469
|
+
yoga.calculateLayout(undefined, undefined, dir);
|
|
470
|
+
} finally {
|
|
471
|
+
for (const leaf of unread) leaf._widthUnread = false;
|
|
472
|
+
}
|
|
473
|
+
contentSpan(boundary, axis, null, this, read);
|
|
449
474
|
restoreShrink(shrunk);
|
|
450
475
|
return;
|
|
451
476
|
}
|
package/src/testing/harness.js
CHANGED
|
@@ -559,32 +559,50 @@ function restoreFrameClock() {
|
|
|
559
559
|
}
|
|
560
560
|
|
|
561
561
|
/**
|
|
562
|
-
* Retry `fn` until it stops throwing, flushing between attempts
|
|
562
|
+
* Retry `fn` until it stops throwing, flushing between attempts, and resolve
|
|
563
|
+
* with what it returned once that is on screen.
|
|
563
564
|
*
|
|
564
565
|
* The tool for anything whose arrival is not a single deterministic step:
|
|
565
566
|
* an async rich-content reflow, an image decode, a timer, or a repaint whose
|
|
566
|
-
* cause travelled through the X connection and back. Each
|
|
567
|
-
*
|
|
568
|
-
*
|
|
567
|
+
* cause travelled through the X connection and back. Each retry is preceded
|
|
568
|
+
* by an `act()`, so waiting also *advances* — this is not a sleep with a
|
|
569
|
+
* condition on it.
|
|
569
570
|
*
|
|
570
571
|
* ```js
|
|
571
572
|
* await waitFor(async () => {
|
|
572
573
|
* assert.notDeepStrictEqual(await pixelAt(ctx, x, y), before);
|
|
573
574
|
* });
|
|
575
|
+
* const row = await waitFor(() => screen.getByText('notes.txt'));
|
|
576
|
+
* await userEvent.click(row);
|
|
574
577
|
* ```
|
|
578
|
+
*
|
|
579
|
+
* **The attempt that passes is followed by an `act()` too.** What it found
|
|
580
|
+
* can have arrived in the sleep before it — an fs read, a timer — and that
|
|
581
|
+
* commits outside act, on React's own scheduler, while the frame that lays
|
|
582
|
+
* it out runs on the window's clock, which need not have ticked since.
|
|
583
|
+
* Without it, a node it returned could have no rect yet, and the click after
|
|
584
|
+
* it would throw `has no laid-out rect yet` (#904). It follows whichever
|
|
585
|
+
* attempt passed, the first included, since the commit can land before
|
|
586
|
+
* `waitFor` is called. And it has to come after the attempt: an `act()`
|
|
587
|
+
* before each one leaves the same gap in its own closing round trip, after
|
|
588
|
+
* the frame it ran.
|
|
575
589
|
*/
|
|
576
590
|
export async function waitFor(fn, { timeout = 2000, interval = 10 } = {}) {
|
|
577
591
|
const deadline = Date.now() + timeout;
|
|
578
592
|
let last;
|
|
579
593
|
for (;;) {
|
|
594
|
+
let found;
|
|
580
595
|
try {
|
|
581
|
-
|
|
596
|
+
found = await fn();
|
|
582
597
|
} catch (err) {
|
|
583
598
|
last = err;
|
|
599
|
+
if (Date.now() >= deadline) throw last;
|
|
600
|
+
await act();
|
|
601
|
+
await new Promise((resolve) => setTimeout(resolve, interval));
|
|
602
|
+
continue;
|
|
584
603
|
}
|
|
585
|
-
if (Date.now() >= deadline) throw last;
|
|
586
604
|
await act();
|
|
587
|
-
|
|
605
|
+
return found;
|
|
588
606
|
}
|
|
589
607
|
}
|
|
590
608
|
|
package/src/testing/index.d.ts
CHANGED
|
@@ -174,6 +174,13 @@ export interface A11ySpy {
|
|
|
174
174
|
uninstall(): void;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
/**
|
|
178
|
+
* Testing Library's four families over the retained node tree: `getBy*`
|
|
179
|
+
* (exactly one, or throw), `queryBy*` (one or null), `getAllBy*` (one or
|
|
180
|
+
* more), and `findBy*` — `getBy*` under {@link waitFor}, with its options and
|
|
181
|
+
* defaults, so it retries until the node appears and resolves once a frame
|
|
182
|
+
* has laid it out.
|
|
183
|
+
*/
|
|
177
184
|
export interface Queries {
|
|
178
185
|
getByText(text: string | RegExp, options?: TextMatchOptions): DrawnNode;
|
|
179
186
|
getAllByText(text: string | RegExp, options?: TextMatchOptions): DrawnNode[];
|
|
@@ -339,7 +346,16 @@ export function setAppearance(values: PinnedAppearance): Promise<void>;
|
|
|
339
346
|
/** Drain in-flight requests on a connection. */
|
|
340
347
|
export function settle(app: NtkApp, roundTrips?: number): Promise<void>;
|
|
341
348
|
|
|
342
|
-
/**
|
|
349
|
+
/**
|
|
350
|
+
* Retry until it stops throwing, `act`-ing between attempts — and once more
|
|
351
|
+
* after the attempt that passed, so what it resolves with is on screen: a
|
|
352
|
+
* node it found has been laid out, and a click on it lands.
|
|
353
|
+
*
|
|
354
|
+
* ```ts
|
|
355
|
+
* const row = await waitFor(() => screen.getByText('notes.txt'));
|
|
356
|
+
* await userEvent.click(row);
|
|
357
|
+
* ```
|
|
358
|
+
*/
|
|
343
359
|
export function waitFor<T>(
|
|
344
360
|
fn: () => T | Promise<T>,
|
|
345
361
|
options?: WaitOptions,
|
package/src/testing/queries.js
CHANGED
|
@@ -12,13 +12,13 @@
|
|
|
12
12
|
// getBy* exactly one, or throw
|
|
13
13
|
// queryBy* one or null, never throws — the way to assert absence
|
|
14
14
|
// getAllBy* one or more, or throw
|
|
15
|
-
// findBy* getBy
|
|
15
|
+
// findBy* getBy* under waitFor: retried until it appears or the timeout
|
|
16
|
+
// expires, and laid out by the time it resolves
|
|
16
17
|
|
|
17
18
|
import { queryAllByComponent, componentInventory } from './components.js';
|
|
19
|
+
import { waitFor } from './harness.js';
|
|
18
20
|
import { roleNameOf } from '../a11y.js';
|
|
19
21
|
|
|
20
|
-
const DEFAULT_TIMEOUT = 1000;
|
|
21
|
-
|
|
22
22
|
/** Depth-first, in paint order, including `<popup>` subtrees. */
|
|
23
23
|
function walk(node, visit) {
|
|
24
24
|
if (!node || node.destroyed) return;
|
|
@@ -97,20 +97,6 @@ function one(root, found, what, printTree = inventory) {
|
|
|
97
97
|
);
|
|
98
98
|
}
|
|
99
99
|
|
|
100
|
-
async function retry(fn, { timeout = DEFAULT_TIMEOUT, interval = 20 } = {}) {
|
|
101
|
-
const deadline = Date.now() + timeout;
|
|
102
|
-
let last;
|
|
103
|
-
for (;;) {
|
|
104
|
-
try {
|
|
105
|
-
return fn();
|
|
106
|
-
} catch (err) {
|
|
107
|
-
last = err;
|
|
108
|
-
if (Date.now() >= deadline) throw last;
|
|
109
|
-
await new Promise((r) => setTimeout(r, interval));
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
100
|
/**
|
|
115
101
|
* The queries, bound to a subtree. `within(node)` is this with a different
|
|
116
102
|
* root; `screen` is this bound to whatever `renderX11` mounted last.
|
|
@@ -199,9 +185,12 @@ export function within(root) {
|
|
|
199
185
|
if (found.length > 1) return one(root, found, label(args[0]));
|
|
200
186
|
return found[0] ?? null;
|
|
201
187
|
};
|
|
188
|
+
// Through waitFor rather than a loop of its own: what this resolves with
|
|
189
|
+
// has to be laid out, or a click on it throws, and waitFor is what runs
|
|
190
|
+
// the frame before resolving
|
|
202
191
|
q[`find${suffix}`] = (...args) => {
|
|
203
192
|
const options = args.length > 1 ? args[args.length - 1] : undefined;
|
|
204
|
-
return
|
|
193
|
+
return waitFor(() => q[`get${suffix}`](...args), options);
|
|
205
194
|
};
|
|
206
195
|
}
|
|
207
196
|
return q;
|