react-x11 2.24.0 → 2.25.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.
package/src/events.js CHANGED
@@ -431,6 +431,10 @@ export class EventManager {
431
431
  this._wheelOwed = { x: 0, y: 0 };
432
432
  // the window's DragSession while a press has armed it (src/dnd.js)
433
433
  this._dragArmed = null;
434
+ // where the pointer last was over this window — the motion or the wheel
435
+ // that said so — for asking again what is under it once what is under
436
+ // it has moved (`refreshHover`). Null while the pointer is elsewhere.
437
+ this._pointer = null;
434
438
  // on a *top-level* window's manager: the chords bound anywhere in its
435
439
  // tree, newest last (`registerAccelerator`). Null until one is bound,
436
440
  // which is the common case and costs one null check per key.
@@ -792,6 +796,7 @@ export class EventManager {
792
796
  * render it started for the notch before.
793
797
  */
794
798
  _onWheel(native) {
799
+ this._pointer = native;
795
800
  // The first wheel is what says this window wants smooth scrolling. It was
796
801
  // created on core events — an XI2 selection costs four times as many
797
802
  // bytes per *pointer move*, which a window that is never scrolled would
@@ -1004,6 +1009,7 @@ export class EventManager {
1004
1009
  }
1005
1010
 
1006
1011
  _onMouseMove(native) {
1012
+ this._pointer = native;
1007
1013
  if (inspectHandler) {
1008
1014
  inspectHandler('move', this._hit(native), native);
1009
1015
  return;
@@ -1090,6 +1096,7 @@ export class EventManager {
1090
1096
  // ancestor was told the pointer had gone and then that it was back, and
1091
1097
  // `onMouseOut` that it had left a window it was still in.
1092
1098
  if (this._leftIntoSurface(native)) return;
1099
+ this._pointer = null;
1093
1100
  runWithPriority(ContinuousEventPriority, () => {
1094
1101
  this._updateHover([], native);
1095
1102
  // the pointer is somewhere else entirely: whatever it was heading for
@@ -1186,6 +1193,41 @@ export class EventManager {
1186
1193
  this.pressPath = path;
1187
1194
  }
1188
1195
 
1196
+ /**
1197
+ * Hover follows content. The pointer stayed where it was and what is
1198
+ * under it moved — a scroll, or a layout that put something else there —
1199
+ * so the question a motion asks is asked again at the point, as a browser
1200
+ * asks it at the frame after a scroll: `:hover`, enter and leave change
1201
+ * as a motion would change them, and the node under the pointer hears
1202
+ * `defaultMouseMove` there, since an element that paints its own hover
1203
+ * (a document, a graph) has content that moved under the point even where
1204
+ * the node did not. The application's `onMouseMove` is not called: the
1205
+ * pointer did not move. Nothing is asked while a press or a capture holds
1206
+ * the pointer, where hover is frozen for the gesture.
1207
+ *
1208
+ * Called by the window after a frame that laid out (nodes/window/flush.js),
1209
+ * which is every frame a scroll moves content in.
1210
+ */
1211
+ refreshHover() {
1212
+ const at = this._pointer;
1213
+ if (!at || inspectHandler || this.node.destroyed) return;
1214
+ if (this._captured() || this.downNode || this._dragArmed) return;
1215
+ runWithPriority(ContinuousEventPriority, () => {
1216
+ const target = this._hit(at);
1217
+ const path = this._path(target);
1218
+ const old = this.hoverPath;
1219
+ if (
1220
+ path.length !== old.length ||
1221
+ path.some((node, i) => node !== old[i])
1222
+ ) {
1223
+ this._updateHover(path, at);
1224
+ }
1225
+ if (typeof target.defaultMouseMove === 'function') {
1226
+ target.defaultMouseMove(this._makeEvent('mouseMove', at, target));
1227
+ }
1228
+ });
1229
+ }
1230
+
1189
1231
  /** enter/leave do not propagate: each node on the diff gets its own call. */
1190
1232
  _updateHover(newPath, native) {
1191
1233
  const oldPath = this.hoverPath;
package/src/node.d.ts CHANGED
@@ -48,6 +48,10 @@ export interface ImageDataLike {
48
48
  * Anything ntk documents beyond this is reachable at runtime and is
49
49
  * ntk's to change.
50
50
  */
51
+ /** One corner's radius for `roundRect`: circular, or elliptical as a
52
+ * `DOMPointInit`. */
53
+ export type RoundRectRadius = number | { x?: number; y?: number };
54
+
51
55
  export interface Context2D {
52
56
  fillStyle: string | CanvasGradientLike;
53
57
  strokeStyle: string | CanvasGradientLike;
@@ -125,12 +129,16 @@ export interface Context2D {
125
129
  anticlockwise?: boolean,
126
130
  ): void;
127
131
  rect(x: number, y: number, width: number, height: number): void;
132
+ /** Canvas's `roundRect`: a radius for every corner, a `{ x, y }` point
133
+ * for an elliptical one, or a list of one to four of either, clockwise
134
+ * from the top left. A shadowed `fill` of one — or of a `rect` less one,
135
+ * filled `'evenodd'` — is drawn from a shadow tile made once. */
128
136
  roundRect(
129
137
  x: number,
130
138
  y: number,
131
139
  width: number,
132
140
  height: number,
133
- radii: number | number[],
141
+ radii: RoundRectRadius | RoundRectRadius[],
134
142
  ): void;
135
143
  fill(fillRule?: 'nonzero' | 'evenodd'): void;
136
144
  stroke(): void;
@@ -515,6 +523,17 @@ export declare class Node {
515
523
  * paint, whatever the props. A translucent element answers `null`.
516
524
  */
517
525
  opaqueRect(): Rect | null;
526
+ /**
527
+ * What this element draws that stays put in the viewport of the scroll
528
+ * pane it is in while the pane scrolls — a document's fixed background, a
529
+ * `position: fixed` header — as rects in window coordinates, device
530
+ * pixels, or null, the default, for drawing that moves with the content.
531
+ * A scroll of that pane that blits repaints them where they are and where
532
+ * the copy dragged their image; one that covers most of the viewport
533
+ * makes the scroll a repaint. Only the nearest scroll pane asks. Read on
534
+ * each scroll the pane blits, after layout.
535
+ */
536
+ viewportFixedRects(): Rect[] | null;
518
537
  /**
519
538
  * How far this element's own drawing reaches past its box, in device
520
539
  * pixels on every side — 0, the default, for an element that stays inside
@@ -267,10 +267,42 @@ export class NodeBoxPaint {
267
267
  ctx.fillShadow(rect, r, shadow.blur, color);
268
268
  continue;
269
269
  }
270
+ // No X server to bake a blur on — CoreGraphics, Direct2D: the
271
+ // context's own shadow, which draws a rounded rect's from a tile it
272
+ // keeps (src/backend/shadowtiles.js). The coverage route below needs
273
+ // RENDER and drew nothing there.
274
+ if (!this.app?.display?.Render) {
275
+ this._paintShadowOnContext(ctx, rect, r, shadow.blur, color);
276
+ continue;
277
+ }
270
278
  this._paintBlurredShadow(ctx, rect, r, shadow.blur, color);
271
279
  }
272
280
  }
273
281
 
282
+ /**
283
+ * One blurred shadow through the context's `shadowBlur`: the rounded rect
284
+ * filled clear of the surface to its left, and its shadow thrown back by
285
+ * as much, so that only the shadow lands. Offsets are device pixels and
286
+ * the shape moves in the context's own units, hence the scale between.
287
+ */
288
+ _paintShadowOnContext(ctx, rect, radius, blur, color) {
289
+ if (!('shadowBlur' in ctx) || typeof ctx.roundRect !== 'function') return;
290
+ const m = ctx.getTransform?.() ?? { a: 1, e: 0 };
291
+ const reach = Math.ceil(blur * 1.5) + 1;
292
+ const away =
293
+ Math.ceil(Math.abs(rect.x) + rect.width + Math.abs(m.e) + reach) + 1;
294
+ ctx.save();
295
+ ctx.shadowColor = color;
296
+ ctx.shadowBlur = blur;
297
+ ctx.shadowOffsetX = away * m.a;
298
+ ctx.shadowOffsetY = 0;
299
+ ctx.fillStyle = '#000000';
300
+ ctx.beginPath();
301
+ ctx.roundRect(rect.x - away, rect.y, rect.width, rect.height, radius);
302
+ ctx.fill();
303
+ ctx.restore();
304
+ }
305
+
274
306
  /**
275
307
  * One blurred shadow, through the paint cache when there is one.
276
308
  *
@@ -114,6 +114,29 @@ export class NodeInvalidate {
114
114
  return null;
115
115
  }
116
116
 
117
+ /**
118
+ * What this element draws that stays put in the viewport of the scroll
119
+ * pane it is in while the pane scrolls, as rects in window coordinates,
120
+ * device pixels, like `abs` — or null, the default, for an element
121
+ * whose drawing moves with the content. A document's fixed background or
122
+ * `position: fixed` header is drawn where the pane's viewport is, not
123
+ * where the content under it is, and a scroll's blit copies the pane's
124
+ * pixels with the content: it dragged the header along, and left the
125
+ * background scrolling with the text.
126
+ *
127
+ * A pane scroll that blits repaints what these rects answer where they
128
+ * are and where the copy dragged their image, as it does the corners of
129
+ * a rounded box around the pane; one that covers most of the viewport
130
+ * makes the scroll a repaint. Only the nearest scroll pane's scroll is
131
+ * asked about: a pane further out moves the inner one, and what is fixed
132
+ * to it, with its content. Read on each scroll the pane blits, after
133
+ * layout, so it may follow `abs` and the pane's `contentBox()`; the
134
+ * element draws those rects in its `paint` from the same geometry.
135
+ */
136
+ viewportFixedRects() {
137
+ return null;
138
+ }
139
+
117
140
  /**
118
141
  * How far this element's own drawing reaches past its box — device
119
142
  * pixels, on every side — or 0, the default, for one that stays inside
package/src/nodes/node.js CHANGED
@@ -607,6 +607,7 @@ export class Node {
607
607
  // nothing else ever asks for a frame
608
608
  this.root?._animating.delete(this);
609
609
  this.root?._opaqueNodes?.delete(this);
610
+ this.root?._viewportFixedNodes?.delete(this);
610
611
  // a surface that goes away takes its selection with it, and the app-wide
611
612
  // claim on being the one showing one goes with it too
612
613
  this._textSelection?.destroy();
@@ -625,11 +626,15 @@ export class Node {
625
626
  this.root?._layoutHosts?.delete(this);
626
627
  root?._layoutHosts?.add(this);
627
628
  }
628
- // an element answering `opaqueRect()` is one the window asks per pass
629
+ // an element answering `opaqueRect()` is one the window asks per pass,
630
+ // and one answering `viewportFixedRects()` one it asks per scroll blit
629
631
  const opaque = this.opaqueRect !== Node.prototype.opaqueRect;
632
+ const fixed = this.viewportFixedRects !== Node.prototype.viewportFixedRects;
630
633
  if (opaque) this.root?._opaqueNodes?.delete(this);
634
+ if (fixed) this.root?._viewportFixedNodes?.delete(this);
631
635
  this.root = root;
632
636
  if (opaque) root?._opaqueNodes?.add(this);
637
+ if (fixed) root?._viewportFixedNodes?.add(this);
633
638
  // styled before it had a window, so this is where a placed node is
634
639
  // first registered (WindowNode._placeNodes)
635
640
  if (this.style && isPlaced(this.style)) root?._placedNodes?.add(this);
@@ -135,9 +135,9 @@ export class NodePaint {
135
135
  * wherever nothing inside it overlaps.
136
136
  */
137
137
  _paintGroup(ctx, opacity) {
138
- const rect = this._groupRect();
138
+ let rect = this._groupRect();
139
139
  if (!rect) return;
140
- const surface = this._groupSurfaceFor(rect.width, rect.height);
140
+ let surface = this._groupSurfaceFor(rect.width, rect.height);
141
141
  const alpha = ctx.globalAlpha ?? 1;
142
142
  if (!surface) {
143
143
  ctx.globalAlpha = alpha * opacity;
@@ -148,17 +148,32 @@ export class NodePaint {
148
148
  }
149
149
  return;
150
150
  }
151
- surface.render((sctx) => {
152
- sctx.clearRect(0, 0, rect.width, rect.height);
153
- sctx.translate(-rect.x, -rect.y);
154
- const damage = this.root?._paintDamage;
155
- if (damage) {
156
- sctx.beginPath();
157
- sctx.rect(damage.x, damage.y, damage.width, damage.height);
158
- sctx.clip();
159
- }
160
- this._paintNode(sctx);
161
- });
151
+ const draw = () =>
152
+ surface.render((sctx) => {
153
+ sctx.clearRect(0, 0, rect.width, rect.height);
154
+ sctx.translate(-rect.x, -rect.y);
155
+ const damage = this.root?._paintDamage;
156
+ if (damage) {
157
+ sctx.beginPath();
158
+ sctx.rect(damage.x, damage.y, damage.width, damage.height);
159
+ sctx.clip();
160
+ }
161
+ this._paintNode(sctx);
162
+ });
163
+ draw();
164
+ // A paragraph learns how far its ink reaches past its box by drawing
165
+ // (`TextNode._keepInkReach`). One its paint lays out at a width nothing
166
+ // measured it at — a box of no width is drawn unwrapped — learns it only
167
+ // here, after the surface was sized, which cut its overflow off on the
168
+ // first frame and drew it whole on every frame after. Drawn once more,
169
+ // at the size it turned out to need: the one time it is not known.
170
+ const after = this._groupRect();
171
+ if (after && !rectContains(rect, after)) {
172
+ rect = after;
173
+ surface = this._groupSurfaceFor(rect.width, rect.height);
174
+ if (!surface) return;
175
+ draw();
176
+ }
162
177
  ctx.globalAlpha = alpha * opacity;
163
178
  try {
164
179
  ctx.drawImage(surface, rect.x, rect.y);
@@ -522,6 +537,16 @@ export class NodePaint {
522
537
  }
523
538
  }
524
539
 
540
+ /** Whether `outer` holds all of `inner`. */
541
+ function rectContains(outer, inner) {
542
+ return (
543
+ inner.x >= outer.x &&
544
+ inner.y >= outer.y &&
545
+ inner.x + inner.width <= outer.x + outer.width &&
546
+ inner.y + inner.height <= outer.y + outer.height
547
+ );
548
+ }
549
+
525
550
  /** WindowNode's half of the paint walk, installed onto `WindowNode.prototype` by window/window.js. */
526
551
  export class WindowPaint {
527
552
  /** Repaint one damage rect, or the whole window when `damage` is null. */
@@ -67,6 +67,8 @@ function contentReach(node, top) {
67
67
  const boxes = layoutDiff.boxes;
68
68
  for (const child of node.children) {
69
69
  if (child.isWindow || !child.yoga || child.hidden) continue;
70
+ // not laid out, and not there: see `absolutize`
71
+ if (child.style.display === 'none') continue;
70
72
  const box = child.yoga.getComputedLayout();
71
73
  // …for the walk that places this child, later in the same one
72
74
  boxes?.set(child, box);
@@ -239,12 +241,20 @@ export const Scrollable = (Base) =>
239
241
  try {
240
242
  this._assignAbs(x, y, width, height);
241
243
  if (this.props.onLayout) this._reportLayout();
242
- this._absolutizeChildren(
243
- this.abs.x,
244
- this.abs.y,
245
- this.abs.x - wasX,
246
- this.abs.y - wasY,
247
- );
244
+ // A `display: 'none'` subtree takes part in no layout: yoga leaves
245
+ // its children where the last pass that reached them did — and a
246
+ // child inserted or moved while it was hidden with no layout at all,
247
+ // NaN, which a scroll pane in there measured its content from and
248
+ // threw on, out of the frame. The box itself has its empty rect;
249
+ // what is inside is placed when it is shown and laid out again.
250
+ if (this.style.display !== 'none') {
251
+ this._absolutizeChildren(
252
+ this.abs.x,
253
+ this.abs.y,
254
+ this.abs.x - wasX,
255
+ this.abs.y - wasY,
256
+ );
257
+ }
248
258
  } finally {
249
259
  if (move) {
250
260
  layoutDiff.shift = null;
@@ -218,6 +218,13 @@ function unionIsBox(a, b) {
218
218
  );
219
219
  }
220
220
 
221
+ /** Two lists of pins as one, either of which may be null. */
222
+ function joinPins(a, b) {
223
+ if (!a) return b;
224
+ if (!b) return a;
225
+ return [...a, ...b];
226
+ }
227
+
221
228
  /** Is `rect` inside one of `pins`, the furniture an element blit pinned —
222
229
  * repaired whole after the copy, whatever changed in it (issue #682)? */
223
230
  function insidePinned(rect, pins) {
@@ -862,7 +869,13 @@ export class WindowScrollBlit {
862
869
  // (issue #691) and repaired below, where they are and where the copy
863
870
  // dragged their image. Refused instead, a list or a document inside a
864
871
  // rounded frame repainted its whole viewport on every step of a scroll.
865
- const corners = this._cornerPins(node, vp, target.top);
872
+ // …and so is what an element in the pane draws fixed to its viewport
873
+ // (`viewportFixedRects()`): a document's fixed background or header,
874
+ // which the copy would drag along with the text under it
875
+ const corners = joinPins(
876
+ this._cornerPins(node, vp, target.top),
877
+ this._viewportFixedPins(node, vp),
878
+ );
866
879
  if (!this._scrollBlitSafe(node, vp, target.top, null, corners)) return;
867
880
  // The band the scrolled bar's thumb travels in is repaired on its own.
868
881
  // The thumb's rects are thin and run along the viewport's edge, and a
@@ -1189,6 +1202,30 @@ export class WindowScrollBlit {
1189
1202
  * Ancestors below `upTo`, the node the blit's surface belongs to
1190
1203
  * (`_blitTarget`): a pane is a rectangle no corner above it clips.
1191
1204
  */
1205
+ /**
1206
+ * What the elements in scroll pane `node` draw fixed to its viewport
1207
+ * (`viewportFixedRects()`), inside `vp` and out to whole pixels: pinned
1208
+ * like a rounded ancestor's corners, repaired where they are and where the
1209
+ * blit dragged their image. Asked only of an element whose nearest scroll
1210
+ * pane is `node` — a pane further in moves with the content, and whatever
1211
+ * is fixed to it with it — and not of one hidden or gone.
1212
+ */
1213
+ _viewportFixedPins(node, vp) {
1214
+ let out = null;
1215
+ for (const fixed of this._viewportFixedNodes ?? []) {
1216
+ if (fixed.destroyed || fixed.hidden || fixed.root !== this) continue;
1217
+ let pane = fixed.parent;
1218
+ while (pane && !pane.isWindow && !pane.isScroller?.()) pane = pane.parent;
1219
+ if (pane !== node) continue;
1220
+ for (const rect of fixed.viewportFixedRects() ?? []) {
1221
+ if (!rectsOverlap(rect, vp)) continue;
1222
+ const inside = intersectRects(outerPixels(rect), vp);
1223
+ if (inside) (out ??= []).push(inside);
1224
+ }
1225
+ }
1226
+ return out;
1227
+ }
1228
+
1192
1229
  _cornerPins(node, vp, upTo = this) {
1193
1230
  let out = null;
1194
1231
  for (let n = node.parent; n && n !== upTo; n = n.parent) {
package/src/nodes/text.js CHANGED
@@ -52,6 +52,72 @@ function outside(ink, box) {
52
52
  /** `TextNode._trim`'s answer, per layout. */
53
53
  const trims = new WeakMap();
54
54
 
55
+ /** {@link glyphInk}'s answer, per layout. */
56
+ const glyphInks = new WeakMap();
57
+
58
+ /**
59
+ * Where a layout's glyphs put ink past its lines' boxes, from its origin —
60
+ * or null where the engine cannot say. A glyph inks past its advance: a `j`
61
+ * that starts a line hooks back over the line's start and an `f` that ends
62
+ * one reaches past its end, by their own boxes (`glyphExtents`, ntk's). And
63
+ * a line set tighter than its face has ascenders over its top and
64
+ * descenders under its bottom, by the face's own reach (`metrics`).
65
+ *
66
+ * Found once per layout, and only where it can leave the layout's box: at a
67
+ * line's two ends, and over the first line and under the last. A glyph an
68
+ * em inside a line's ends reaches neither end, and measuring every glyph
69
+ * cost about as much again as laying the paragraph out.
70
+ */
71
+ function glyphInk(layout) {
72
+ const known = glyphInks.get(layout);
73
+ if (known !== undefined) return known;
74
+ let left = Infinity;
75
+ let top = Infinity;
76
+ let right = -Infinity;
77
+ let bottom = -Infinity;
78
+ const lines = layout.lines;
79
+ for (let i = 0; i < lines.length; i++) {
80
+ const line = lines[i];
81
+ const outer = i === 0 || i === lines.length - 1;
82
+ const start = line.x;
83
+ const end = line.x + line.width;
84
+ for (const placed of line.runs ?? []) {
85
+ const run = placed.run;
86
+ if (!run?.glyphs) continue;
87
+ if (outer) {
88
+ const m = run.font?.metrics?.(run.size);
89
+ if (m && m.ascent >= 0 && m.descent >= 0) {
90
+ top = Math.min(top, line.baseline - m.ascent);
91
+ bottom = Math.max(bottom, line.baseline + m.descent);
92
+ }
93
+ }
94
+ // the pen as `layout.draw` moves it
95
+ let pen = line.x + placed.x;
96
+ for (const glyph of run.glyphs) {
97
+ const gx = pen + glyph.dx;
98
+ const gy = line.baseline - glyph.dy;
99
+ pen += glyph.ax;
100
+ if (gx - run.size > start && gx + run.size < end) continue;
101
+ // a face that cannot say leaves its glyphs to the lines' boxes, and
102
+ // a blank glyph — a space — has no ink to reach anywhere with
103
+ const face = glyph.font ?? run.font;
104
+ const e = face?.glyphExtents?.(glyph.id, run.size);
105
+ if (!e || !(e.maxX > e.minX && e.maxY > e.minY)) continue;
106
+ left = Math.min(left, gx + e.minX);
107
+ right = Math.max(right, gx + e.maxX);
108
+ if (outer) {
109
+ top = Math.min(top, gy + e.minY);
110
+ bottom = Math.max(bottom, gy + e.maxY);
111
+ }
112
+ }
113
+ }
114
+ }
115
+ const ink =
116
+ left <= right || top <= bottom ? { left, top, right, bottom } : null;
117
+ glyphInks.set(layout, ink);
118
+ return ink;
119
+ }
120
+
55
121
  /** The size under which this connection's text is painted as strips. */
56
122
  export function setTextStripBelow(app, below) {
57
123
  if (below === undefined) return;
@@ -739,7 +805,8 @@ export class TextNode extends Node {
739
805
  if (placed) layoutDiff.sink(insetRect(placed, -DAMAGE_SLOP), this);
740
806
  }
741
807
 
742
- /** The rect the lines of a placed layout cover, in window coordinates. */
808
+ /** The rect the lines of a placed layout cover, and their glyphs' ink
809
+ * where it reaches past them, in window coordinates. */
743
810
  _inkOf({ layout, x, y }) {
744
811
  const lines = layout.lines;
745
812
  if (!lines?.length) return null;
@@ -749,11 +816,21 @@ export class TextNode extends Node {
749
816
  left = Math.min(left, line.x);
750
817
  right = Math.max(right, line.x + line.width);
751
818
  }
819
+ let top = 0;
820
+ let bottom = layout.height;
821
+ const glyphs = glyphInk(layout);
822
+ if (glyphs) {
823
+ // each is ±Infinity where nothing was found to reach that way
824
+ left = Math.min(left, glyphs.left);
825
+ right = Math.max(right, glyphs.right);
826
+ top = Math.min(top, glyphs.top);
827
+ bottom = Math.max(bottom, glyphs.bottom);
828
+ }
752
829
  return {
753
830
  x: x + left,
754
- y,
831
+ y: y + top,
755
832
  width: right - left,
756
- height: layout.height,
833
+ height: bottom - top,
757
834
  };
758
835
  }
759
836
 
@@ -56,7 +56,7 @@ export function captureLeafHeights(node, out) {
56
56
  * is back to meaning only how eagerly the space *above* the floor is given
57
57
  * up.
58
58
  */
59
- function namesOwnFloor(node, axis) {
59
+ export function namesOwnFloor(node, axis) {
60
60
  const style = node.style;
61
61
  if (style.overflow === 'scroll' || style.overflow === 'hidden') return true;
62
62
  return (
@@ -138,6 +138,14 @@ export class WindowFlush {
138
138
  if (!this.destroyed) this._scheduleFrame();
139
139
  }
140
140
  }
141
+ // A frame that laid out may have moved what is under a pointer that did
142
+ // not move — a scroll does, every frame of it — and hover follows the
143
+ // content (`EventManager.refreshHover`). After the frame: what hover
144
+ // changes is the next frame's to paint.
145
+ if (this._hoverRecheck) {
146
+ this._hoverRecheck = false;
147
+ if (!this.destroyed) this.events?.refreshHover();
148
+ }
141
149
  }
142
150
 
143
151
  /** The frame itself. True when it painted or presented something. */
@@ -289,7 +297,11 @@ export class WindowFlush {
289
297
  // like `presentFrame`; an ntk window has no such half.
290
298
  this.window.prepareFrame?.(this, layoutRan);
291
299
  // any node this pass laid out may be what an open popup is anchored to
292
- if (layoutRan) this._notifyAnchorChange();
300
+ // — or what a pointer that has not moved is over now (`flush`)
301
+ if (layoutRan) {
302
+ this._notifyAnchorChange();
303
+ this._hoverRecheck = true;
304
+ }
293
305
  // A subtree that only moved and covers its box opaquely is copied where
294
306
  // it went, and the frame paints around the copy — or its move is
295
307
  // claimed, where it was and where it is (nodes/moveblit.js). Once every
@@ -2,7 +2,7 @@
2
2
  // floors, laying the root out, deferring floors during a live resize and
3
3
  // catching up after, and refitting the window to what it holds.
4
4
 
5
- import { measuringExactly } from '../../styles.js';
5
+ import { exactLayoutConfig, measuringExactly } from '../../styles.js';
6
6
  import { availableArea } from '../../screens.js';
7
7
  import { Yoga } from '../../yoga.js';
8
8
  import {
@@ -11,6 +11,7 @@ import {
11
11
  columnHeightSpan,
12
12
  markUnreadLeaves,
13
13
  declaresOwnMinimum,
14
+ namesOwnFloor,
14
15
  dirtyOutside,
15
16
  floorBoundaryOf,
16
17
  probeHeightFloors,
@@ -327,12 +328,18 @@ export class WindowSize {
327
328
 
328
329
  /**
329
330
  * One spine root's floors, measured with it laid out alone — at the width
330
- * it has in each of the passes over the whole tree (`spine.js`), and with
331
- * no height on offer in either height pass, because down a column whose
332
- * height is its content's the pass over the whole tree has none to offer
333
- * it either. Its own extents are written here: unlike a box that sizes
334
- * itself, a root's are its content's, and the column it sits in is summed
335
- * from them (`_sumSpine`).
331
+ * it has in each of the passes over the whole tree (`spine.js`), and, for
332
+ * its heights, as the one item of a column that offers it what each of
333
+ * those passes does: no height in the first, and in the collapse what
334
+ * the column it sits in offers there (`frameFor`), which is 0 under the
335
+ * window. Laid out as an item rather than as a root, it is resolved as
336
+ * its column resolves it there — a basis counts, a floor of its own is
337
+ * given way to — and what it holds is laid out in the bound yoga passes
338
+ * down, which squashes a pane in it that names a floor: laid out with no
339
+ * height at all, a <Tree>'s pane came out as tall as its 100,000 rows.
340
+ * Its own extents are written here: unlike a box that sizes itself, a
341
+ * root's are its content's, and the column it sits in is summed from
342
+ * them (`_sumSpine`).
336
343
  */
337
344
  _measureSpineRoot(root, widths, axis, probe = false) {
338
345
  onExactCopy(root, () =>
@@ -356,33 +363,50 @@ export class WindowSize {
356
363
  restoreShrink(shrunk);
357
364
  return;
358
365
  }
359
- this._scopedFloorPasses += 1;
360
- yoga.calculateLayout(widths.width, undefined, dir);
361
- if (probe) {
362
- const hit = { marked: false, owed: false };
363
- probeHeightFloors(root, this, hit, root);
364
- if (hit.marked) {
365
- this._writeFloors('height');
366
- this._scopedFloorPasses += 1;
367
- yoga.calculateLayout(widths.width, undefined, dir);
366
+ // the column it is laid out in (`frameFor`), as wide as the one it sits in
367
+ const column = Yoga.Node.create(exactLayoutConfig());
368
+ if (widths.frame.scrolls) column.setOverflow(Yoga.OVERFLOW_SCROLL);
369
+ column.insertChild(yoga, 0);
370
+ const pass = (height) => {
371
+ this._scopedFloorPasses += 1;
372
+ column.calculateLayout(widths.width, height, dir);
373
+ };
374
+ try {
375
+ pass(undefined);
376
+ if (probe) {
377
+ const hit = { marked: false, owed: false };
378
+ probeHeightFloors(root, this, hit, root);
379
+ if (hit.marked) {
380
+ this._writeFloors('height');
381
+ pass(undefined);
382
+ }
368
383
  }
384
+ const intrinsic = new Map();
385
+ captureLeafHeights(root, intrinsic);
386
+ const frozen = [];
387
+ freezeWidths(root, frozen);
388
+ const shrunk = [];
389
+ setMeasuringShrink(root, axis, shrunk);
390
+ // …and gives way in it as `setMeasuringShrink` has everything else
391
+ // give way: to a floor it names, and to nothing else
392
+ const shrink = namesOwnFloor(root, axis) ? 1 : 0;
393
+ if ((root.style.flexShrink ?? 1) !== shrink) {
394
+ yoga.setFlexShrink(shrink);
395
+ shrunk.push(root);
396
+ }
397
+ pass(widths.frame.height);
398
+ const span = contentSpan(root, axis, intrinsic, this);
399
+ root._floorH = declaresOwnMinimum(root, axis)
400
+ ? yoga.getComputedHeight()
401
+ : span;
402
+ root._floorAtW = yoga.getComputedWidth();
403
+ this._floorsMeasured += 1;
404
+ restoreWidths(frozen);
405
+ restoreShrink(shrunk);
406
+ } finally {
407
+ column.removeChild(yoga);
408
+ column.free();
369
409
  }
370
- const intrinsic = new Map();
371
- captureLeafHeights(root, intrinsic);
372
- const frozen = [];
373
- freezeWidths(root, frozen);
374
- const shrunk = [];
375
- setMeasuringShrink(root, axis, shrunk);
376
- this._scopedFloorPasses += 1;
377
- yoga.calculateLayout(widths.width, undefined, dir);
378
- const span = contentSpan(root, axis, intrinsic, this);
379
- root._floorH = declaresOwnMinimum(root, axis)
380
- ? yoga.getComputedHeight()
381
- : span;
382
- root._floorAtW = yoga.getComputedWidth();
383
- this._floorsMeasured += 1;
384
- restoreWidths(frozen);
385
- restoreShrink(shrunk);
386
410
  }
387
411
 
388
412
  /** The columns of this frame's spines, deepest first, summed from their