react-x11 2.23.2 → 2.24.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.23.2",
3
+ "version": "2.24.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -40,6 +40,9 @@ class CompositingSession {
40
40
  this.stopped = false;
41
41
  this._window = null;
42
42
  this._selection = null;
43
+ /** What `stop` takes off the connection: the listener and the XFixes
44
+ * address window `watchSelection` put on it. */
45
+ this._teardown = [];
43
46
  }
44
47
 
45
48
  _set(supported) {
@@ -64,6 +67,16 @@ class CompositingSession {
64
67
  stop() {
65
68
  this.stopped = true;
66
69
  this.listeners.clear();
70
+ // Taken off, not just ignored — the same as XSETTINGS's session: an app
71
+ // that mounts one root after another on one connection gained a
72
+ // listener and a window a root.
73
+ for (const undo of this._teardown.splice(0)) {
74
+ try {
75
+ undo();
76
+ } catch {
77
+ // a connection already gone takes all of it with it
78
+ }
79
+ }
67
80
  }
68
81
  }
69
82
 
@@ -144,13 +157,20 @@ async function watchSelection(app, session, atom) {
144
157
  fixes.SelectionEventMask.SetSelectionOwner |
145
158
  fixes.SelectionEventMask.SelectionWindowDestroy |
146
159
  fixes.SelectionEventMask.SelectionClientClose;
147
- X.on('event', (ev) => {
160
+ const onOwner = (ev) => {
148
161
  if (session.stopped) return;
149
162
  // XFixes events carry a server-assigned type above the core range, so
150
163
  // this cannot be confused with core SelectionNotify (31)
151
164
  if (ev.type !== fixes.firstEvent) return;
152
165
  if (ev.selection !== atom) return;
153
166
  session._set(Boolean(ev.owner));
167
+ };
168
+ X.on('event', onOwner);
169
+ session._teardown.push(() => {
170
+ X.removeListener('event', onOwner);
171
+ // the selection input goes with the window it was addressed to
172
+ X.DestroyWindow(id);
173
+ session._window = null;
154
174
  });
155
175
  fixes.SelectSelectionInput(id, atom, mask);
156
176
  }
package/src/node.d.ts CHANGED
@@ -515,6 +515,16 @@ export declare class Node {
515
515
  * paint, whatever the props. A translucent element answers `null`.
516
516
  */
517
517
  opaqueRect(): Rect | null;
518
+ /**
519
+ * How far this element's own drawing reaches past its box, in device
520
+ * pixels on every side — 0, the default, for an element that stays inside
521
+ * it. Core claims and culls the element by its box grown by this, as it
522
+ * does for an outline or a shadow; an element that paints past its box
523
+ * without saying so leaves the old ink's edge behind when it changes. Read
524
+ * when the cached paint bounds are rebuilt: answer a bound for anything
525
+ * the element may draw, not for what it drew last.
526
+ */
527
+ paintOverhang(): number;
518
528
  /**
519
529
  * The rect this paint pass is repainting, or null when it is repainting
520
530
  * the whole window — and null outside a paint, which means the same
@@ -114,6 +114,26 @@ export class NodeInvalidate {
114
114
  return null;
115
115
  }
116
116
 
117
+ /**
118
+ * How far this element's own drawing reaches past its box — device
119
+ * pixels, on every side — or 0, the default, for one that stays inside
120
+ * it. Core claims and culls the element, and sizes a fade's surface, by
121
+ * the box grown by it, as it does for an outline or a shadow. Drawing
122
+ * past the box without saying so, an element was claimed by its box
123
+ * alone: an edit left the old ink's edge behind and drew the new one's
124
+ * without it, and a row scrolled just out of view lost the ink it still
125
+ * put in. A code chip padded past the run it sits behind is the case —
126
+ * at the start of a line the pad is outside the box.
127
+ *
128
+ * Read whenever the cached paint bounds are rebuilt, which a claim of the
129
+ * node does: answer a bound that holds for anything the element may
130
+ * draw, not for what it drew last, or the claim after a change stops
131
+ * covering what the frame before put down.
132
+ */
133
+ paintOverhang() {
134
+ return 0;
135
+ }
136
+
117
137
  /**
118
138
  * A layout-affecting change confined to this node: claim the subtree as
119
139
  * it stands now, and queue it for a second claim once layout has run —
@@ -206,7 +226,8 @@ export class NodeInvalidate {
206
226
 
207
227
  /**
208
228
  * This node's own rect, grown by anything it draws outside it — the
209
- * outline and the shadow, the only two. Per node rather than once at the
229
+ * outline and the shadow, and whatever an element says it draws past its
230
+ * box (`paintOverhang`). Per node rather than once at the
210
231
  * top, because either can belong to any node and the bound has to cover it
211
232
  * wherever it is; and the outline is counted even when the ring is
212
233
  * currently *off*, because the frame that erases it is claimed after the
@@ -219,7 +240,11 @@ export class NodeInvalidate {
219
240
  * instead, where both the old style and the new one are in hand.
220
241
  */
221
242
  _ownPaintBounds() {
222
- const extent = Math.max(this._outlineExtent(), this._shadowExtent());
243
+ const extent = Math.max(
244
+ this._outlineExtent(),
245
+ this._shadowExtent(),
246
+ this.paintOverhang() || 0,
247
+ );
223
248
  if (extent <= 0) return this.abs;
224
249
  return {
225
250
  x: this.abs.x - extent,
@@ -549,15 +574,6 @@ export class WindowInvalidate {
549
574
  return damageToPaint(rects);
550
575
  }
551
576
 
552
- /**
553
- * The rects this frame will repaint, or null for the whole window.
554
- *
555
- * Clamped to the window: damage is recorded when a node invalidates, and
556
- * the window may have been resized since. A region that no longer
557
- * intersects the window means there is nothing to do, but the frame still
558
- * has to clear the flag, so it degrades to a full repaint rather than
559
- * painting nothing.
560
- */
561
577
  /**
562
578
  * How many rects a frame's damage may hold before `addDamageRect` merges
563
579
  * the closest pair. Four on X11 (`MAX_DAMAGE_RECTS`), where every pass
@@ -571,6 +587,17 @@ export class WindowInvalidate {
571
587
  return Number.isInteger(cap) && cap > 0 ? cap : MAX_DAMAGE_RECTS;
572
588
  }
573
589
 
590
+ /**
591
+ * The rects this frame will repaint, or null for the whole window.
592
+ *
593
+ * Clamped to the window: damage is recorded when a node invalidates, and
594
+ * the window may have been resized since — or the node drew past its
595
+ * edge, where a claim lands whole. A frame whose every claim lies outside
596
+ * the window has nothing to repaint and paints nothing, as a frame whose
597
+ * claims were all answered on layers does; it used to repaint the whole
598
+ * window, so a card dragged past the edge of a graph, or a pan that
599
+ * claimed only its far side, cost a full frame.
600
+ */
574
601
  _takeDamage(width, height) {
575
602
  const damage = this._damage;
576
603
  this._damage = null;
@@ -608,7 +635,11 @@ export class WindowInvalidate {
608
635
  if (clamped === FULL_DAMAGE) return null;
609
636
  if (clamped) rects.push(clamped);
610
637
  }
611
- if (!rects.length) return null;
638
+ if (!rects.length) {
639
+ this._lastDamageRects = [];
640
+ this._lastDamage = { x: 0, y: 0, width: 0, height: 0 };
641
+ return [];
642
+ }
612
643
  this._lastDamageRects = damageToPaint(rects);
613
644
  this._lastDamage = rectsBounds(this._lastDamageRects);
614
645
  return this._lastDamageRects;
@@ -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);
@@ -355,11 +370,15 @@ export class NodePaint {
355
370
  }
356
371
 
357
372
  /** `order` painted in turn, each child culled against the window, its
358
- * clipping ancestors and the pass. */
373
+ * clipping ancestors and the pass — by what it paints, not by its box: a
374
+ * row scrolled just out of a pane still reaches into it with whatever
375
+ * hangs off it, a descender past a trimmed line box or a child that
376
+ * overflows it, and culled by its box it drew none of that in a full
377
+ * repaint while a scroll's copy carried it. */
359
378
  _paintChildList(ctx, order) {
360
379
  for (const child of order) {
361
380
  if (child._promoted) continue; // on a layer of its own: a hole here
362
- if (child._offscreen(child.abs, DAMAGE_SLOP)) continue;
381
+ if (child._offscreen(child._subtreeBounds(), DAMAGE_SLOP)) continue;
363
382
  if (child._outsideDamage()) continue;
364
383
  child.paint(ctx);
365
384
  }
@@ -371,8 +390,9 @@ export class NodePaint {
371
390
  */
372
391
  _paintsInto(rect) {
373
392
  if (this._promoted) return false;
374
- if (!rectsOverlap(this._subtreeBounds(), rect, DAMAGE_SLOP)) return false;
375
- return !this._offscreen(this.abs, DAMAGE_SLOP);
393
+ const reach = this._subtreeBounds();
394
+ if (!rectsOverlap(reach, rect, DAMAGE_SLOP)) return false;
395
+ return !this._offscreen(reach, DAMAGE_SLOP);
376
396
  }
377
397
 
378
398
  /**
@@ -517,6 +537,16 @@ export class NodePaint {
517
537
  }
518
538
  }
519
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
+
520
550
  /** WindowNode's half of the paint walk, installed onto `WindowNode.prototype` by window/window.js. */
521
551
  export class WindowPaint {
522
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;
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 (
@@ -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
@@ -15,8 +15,13 @@
15
15
  //
16
16
  // - Down a column whose height is its content's, the measuring passes have
17
17
  // no free space to squash into, so a block in it is laid out at its
18
- // content height in every pass. Laid out alone with no height on offer,
19
- // it comes out the same.
18
+ // content height in every pass. What it holds is laid out in the height
19
+ // the pass offers the column, though — none under a pane that scrolls,
20
+ // and 0 under the window, which the collapse lays out at 0 and every box
21
+ // on the way down passes on as a bound — and a bound is not nothing: a
22
+ // pane in the block that names a floor is squashed to it, and a basis
23
+ // counts. Laid out alone as the one item of a column that offers the
24
+ // same (`frameFor`), the block comes out the same.
20
25
  // - Across, a block stretched over its column is as wide as the column's
21
26
  // content box, in every pass: now, at the width the heights are measured
22
27
  // for, and in the width pass, whose width the way down from the root
@@ -30,7 +35,12 @@
30
35
  // Anything else — a row on the way, a margin or a width that is not a
31
36
  // length — is not a spine, and the tree is measured whole.
32
37
 
33
- import { declaresOwnMinimum, inFlow, mainAxisOf } from './floors.js';
38
+ import {
39
+ declaresOwnMinimum,
40
+ inFlow,
41
+ mainAxisOf,
42
+ namesOwnFloor,
43
+ } from './floors.js';
34
44
  import { Yoga } from '../../yoga.js';
35
45
 
36
46
  const LENGTHS_ACROSS = [
@@ -149,7 +159,7 @@ export function spineScope(root, sources, listOnly) {
149
159
  if (!child.yoga || child.isWindow || child.style.display === 'none') {
150
160
  continue;
151
161
  }
152
- const widths = rootWidths(child, offered);
162
+ const widths = rootWidths(child, offered, node);
153
163
  if (widths === null) return null;
154
164
  roots.set(child, widths);
155
165
  }
@@ -161,6 +171,11 @@ export function spineScope(root, sources, listOnly) {
161
171
  return false;
162
172
  };
163
173
  for (const node of [...roots.keys()]) if (within(node)) roots.delete(node);
174
+ // …and one that is not has to be laid out alone as the whole tree lays it
175
+ // out, which only the pass knows for some (`frameFor`)
176
+ for (const [node, widths] of roots) {
177
+ if (!heightHolds(node, widths.frame)) return null;
178
+ }
164
179
  for (const node of [...spine]) {
165
180
  if (roots.has(node) || within(node)) spine.delete(node);
166
181
  }
@@ -186,6 +201,121 @@ export function spineScope(root, sources, listOnly) {
186
201
  };
187
202
  }
188
203
 
204
+ /**
205
+ * The column a root is laid out alone as the one item of, for its heights
206
+ * (`_measureSpineRoot`): one that lays it out in what the collapse pass
207
+ * over the whole tree offers the column it sits in.
208
+ *
209
+ * - `COLLAPSED` under the window, which that pass lays out at 0, and under
210
+ * a stop that only names its floors or clips when every box on the way
211
+ * up from it passes that 0 on (`passesBound`). Every column the spine
212
+ * sums passes it on too, having no height of its own — unless it names
213
+ * a basis or an aspect ratio to lay out what it holds in instead.
214
+ * - `UNBOUNDED` in a column under a pane that scrolls, which measures what
215
+ * it holds with no bound. The column is laid out at its content's height,
216
+ * as long as the pane's own item on the way has no basis to start from.
217
+ * - An item of the pane itself is laid out in the pane's height: 0 when
218
+ * the pane is squashed to nothing, as a pane under the window is, which
219
+ * the frame copies by scrolling too (`SQUASHED_PANE`); in any other
220
+ * pane's, which only the pass knows (`OPEN_PANE`).
221
+ *
222
+ * `null` where a box on the way lays out what it holds in a height of its
223
+ * own, which only the pass knows too.
224
+ */
225
+ function frameFor(column, stop) {
226
+ if (stop.style.overflow === 'scroll') {
227
+ if (column === stop) {
228
+ return boundFrom(stop) ? SQUASHED_PANE : OPEN_PANE;
229
+ }
230
+ for (let node = column; node !== stop; node = node.parent) {
231
+ if (node.style.aspectRatio > 0) return null;
232
+ if (node.parent === stop && basisOf(node.style) !== 'auto') return null;
233
+ }
234
+ return UNBOUNDED;
235
+ }
236
+ for (let node = column; node !== stop; node = node.parent) {
237
+ if (!passesBound(node)) return null;
238
+ }
239
+ return boundFrom(stop) ? COLLAPSED : null;
240
+ }
241
+
242
+ const COLLAPSED = Object.freeze({ height: 0, scrolls: false });
243
+ const UNBOUNDED = Object.freeze({ height: undefined, scrolls: false });
244
+ const SQUASHED_PANE = Object.freeze({ height: 0, scrolls: true });
245
+ // laid out with no bound, as `UNBOUNDED` — for the roots that nothing about
246
+ // the pane's height decides (`heightHolds`)
247
+ const OPEN_PANE = Object.freeze({ height: undefined, scrolls: false });
248
+
249
+ /** Whether the collapse's 0 reaches what `node` holds: no box from it up to
250
+ * the window has a height of its own to replace it with, and none above
251
+ * it scrolls, measuring what it holds with no bound. */
252
+ function boundFrom(node) {
253
+ for (let n = node; !n.isWindow; n = n.parent) {
254
+ if (!n.parent) return false;
255
+ if (n !== node && n.style.overflow === 'scroll') return false;
256
+ if (!passesBound(n)) return false;
257
+ }
258
+ return true;
259
+ }
260
+
261
+ /**
262
+ * Whether a box lays out what it holds in the bound it is laid out in
263
+ * itself: no height of its own named, no floor above nothing, no basis but
264
+ * 0 and no aspect ratio to take one from. Asked of the style, as everything
265
+ * here is — yoga's getters are a call into the engine and an object each,
266
+ * and a fling asks this of every box up to the window — and it is the
267
+ * style's floor that counts: the one the floors wrote is taken off a box a
268
+ * change is inside before anything is measured (`collectFloorStale`), and
269
+ * every box asked here is one.
270
+ */
271
+ function passesBound(node) {
272
+ const style = node.style;
273
+ if (style.height !== undefined && style.height !== 'auto') return false;
274
+ if (style.minHeight !== undefined && style.minHeight !== 0) return false;
275
+ const basis = basisOf(style);
276
+ if (share(basis) || basis > 0) return false;
277
+ return !(style.aspectRatio > 0);
278
+ }
279
+
280
+ /** A length given as a share of something — a percentage — rather than as
281
+ * a number or `auto`. */
282
+ const share = (value) => typeof value === 'string' && value !== 'auto';
283
+
284
+ /** The basis a style gives a flex item, as yoga takes it: the longhand, or
285
+ * the shorthand's — 0 for a number, `auto` for either keyword. */
286
+ function basisOf(style) {
287
+ if (style.flexBasis !== undefined) return style.flexBasis;
288
+ return typeof style.flex === 'number' ? 0 : 'auto';
289
+ }
290
+
291
+ /**
292
+ * Whether a root laid out alone in `frame` comes out as the whole tree lays
293
+ * it out: there is a frame; no length on its height axis is a share of the
294
+ * column it is not laid out in here; and in a pane that is not squashed,
295
+ * whose height only the pass knows, the root's own height is not decided
296
+ * by it — it does not grow into it, give way to it with a floor, or start
297
+ * from a basis.
298
+ */
299
+ function heightHolds(root, frame) {
300
+ if (frame === null) return false;
301
+ const style = root.style;
302
+ const basis = basisOf(style);
303
+ if (
304
+ share(style.height) ||
305
+ share(style.minHeight) ||
306
+ share(style.maxHeight) ||
307
+ share(basis)
308
+ ) {
309
+ return false;
310
+ }
311
+ if (frame !== OPEN_PANE) return true;
312
+ return !(
313
+ root.yoga.getFlexGrow() > 0 ||
314
+ namesOwnFloor(root, 'height') ||
315
+ basis !== 'auto'
316
+ );
317
+ }
318
+
189
319
  const OUT_OF_FLOW = 1;
190
320
  const RELATIVE_MARGIN = 2;
191
321
  const STYLE_FLAGS = new WeakMap();
@@ -240,7 +370,7 @@ function staleChildren(node) {
240
370
  * that is not a length, a percentage or `auto`, ends one: `auto` centres the
241
371
  * box instead of stretching it.
242
372
  */
243
- function rootWidths(node, offered) {
373
+ function rootWidths(node, offered, stop) {
244
374
  const style = node.style;
245
375
  const parent = node.parent;
246
376
  if (!parent || !inFlow(node) || node._host !== null) return null;
@@ -249,12 +379,13 @@ function rootWidths(node, offered) {
249
379
  if (!stretched(node)) return null;
250
380
  if (MARGINS.some((key) => typeof style[key] === 'string')) return null;
251
381
  if (PADDINGS.some((key) => typeof style[key] === 'string')) return null;
252
- return offered.in(parent);
382
+ return offered.in(parent, stop);
253
383
  }
254
384
 
255
385
  /**
256
386
  * The widths a column offers the roots in it (`rootWidths`), worked out once
257
- * a column rather than once a root.
387
+ * a column rather than once a root — and the column they are laid out in
388
+ * for their heights (`frameFor`), the same for every root in it.
258
389
  *
259
390
  * Every root in a column is offered the same two, and working out the width
260
391
  * pass's walks the way up to the window, with four yoga reads a box for its
@@ -272,14 +403,16 @@ class OfferedWidths {
272
403
  this.passWidths = new Map();
273
404
  }
274
405
 
275
- /** `{ width, widthPass }` for a root in `column`: its content box now,
276
- * and in the width pass over the whole tree. */
277
- in(column) {
406
+ /** `{ width, widthPass, frame }` for a root in `column`: its content box
407
+ * now, and in the width pass over the whole tree; and the column it is
408
+ * laid out in, under `stop`. */
409
+ in(column, stop) {
278
410
  let offer = this.offers.get(column);
279
411
  if (offer === undefined) {
280
412
  offer = {
281
413
  width: Math.max(0, column.yoga.getComputedWidth() - this.inset(column)),
282
414
  widthPass: this.passContent(column),
415
+ frame: frameFor(column, stop),
283
416
  };
284
417
  this.offers.set(column, offer);
285
418
  }
package/src/xsettings.js CHANGED
@@ -127,6 +127,9 @@ class XSettingsSession {
127
127
  this.stopped = false;
128
128
  this._selectionAtom = 0;
129
129
  this._propertyAtom = 0;
130
+ /** What `stop` takes off the connection: the event listeners and the
131
+ * XFixes address window `watchOwner` put on it. */
132
+ this._teardown = [];
130
133
  }
131
134
 
132
135
  _set(values) {
@@ -150,6 +153,17 @@ class XSettingsSession {
150
153
  stop() {
151
154
  this.stopped = true;
152
155
  this.listeners.clear();
156
+ // Taken off, not just ignored: a listener that checks `stopped` and
157
+ // returns still runs for every event on the connection, and an app that
158
+ // mounts one root after another — a test suite, a hot reload — gained a
159
+ // listener and a window a root.
160
+ for (const undo of this._teardown.splice(0)) {
161
+ try {
162
+ undo();
163
+ } catch {
164
+ // a connection already gone takes all of it with it
165
+ }
166
+ }
153
167
  }
154
168
  }
155
169
 
@@ -238,8 +252,12 @@ async function readSettings(session) {
238
252
  async function watchOwner(session) {
239
253
  const app = session.app;
240
254
  const X = app.X;
255
+ // Started without being awaited, so a root unmounted in the meantime has
256
+ // already stopped the session — and a listener added now would be one
257
+ // `stop` never saw.
258
+ if (session.stopped) return;
241
259
 
242
- X.on('event', (ev) => {
260
+ const onProperty = (ev) => {
243
261
  if (session.stopped) return;
244
262
  if (
245
263
  ev.type === PROPERTY_NOTIFY &&
@@ -251,7 +269,9 @@ async function watchOwner(session) {
251
269
  () => {},
252
270
  );
253
271
  }
254
- });
272
+ };
273
+ X.on('event', onProperty);
274
+ session._teardown.push(() => X.removeListener('event', onProperty));
255
275
 
256
276
  const fixes = await requireExtension(app, 'fixes');
257
277
  if (!fixes || session.stopped) return;
@@ -261,11 +281,17 @@ async function watchOwner(session) {
261
281
  X.CreateWindow(id, app.display.screen[0].root, -10, -10, 1, 1, 0, 0, 2, 0, {
262
282
  eventMask: 0,
263
283
  });
264
- X.on('event', (ev) => {
284
+ const onOwner = (ev) => {
265
285
  if (session.stopped) return;
266
286
  if (ev.type !== fixes.firstEvent) return;
267
287
  if (ev.selection !== session._selectionAtom) return;
268
288
  latch(session).catch(() => {});
289
+ };
290
+ X.on('event', onOwner);
291
+ session._teardown.push(() => {
292
+ X.removeListener('event', onOwner);
293
+ // the selection input goes with the window it was addressed to
294
+ X.DestroyWindow(id);
269
295
  });
270
296
  fixes.SelectSelectionInput(
271
297
  id,