react-x11 2.23.1 → 2.24.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.23.1",
3
+ "version": "2.24.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
package/src/cocoa/app.js CHANGED
@@ -762,7 +762,7 @@ export class CocoaApp {
762
762
  /**
763
763
  * The pane's end of the frame channel (childmain hands it over,
764
764
  * feature-detected so the X11 pane path never notices): geometry and
765
- * input come in, pane-present goes out.
765
+ * input come in, pane-present and pane-cursor go out.
766
766
  */
767
767
  attachPaneChannel(channel) {
768
768
  if (!this._paneMode) return;
@@ -12,8 +12,8 @@
12
12
  // What the node tree sees is the ordinary window contract: getContext,
13
13
  // present, scrollRegion, noteFrameDamage, requestAnimationFrame, events
14
14
  // via emit. Geometry and input arrive as channel messages (the host owns
15
- // layout and hit-testing — this is CPU offloading, not isolation), and the
16
- // only outbound traffic is pane-present.
15
+ // layout and hit-testing — this is CPU offloading, not isolation), and what
16
+ // goes out is pane-present, and pane-cursor for the cursor the host shows.
17
17
  import { BackendContext2D } from '../backend/context2d.js';
18
18
 
19
19
  let nextPaneId = 1;
@@ -95,6 +95,17 @@ export class CocoaPaneWindow {
95
95
 
96
96
  setTitle() {}
97
97
 
98
+ /**
99
+ * The cursor this pane's tree names, sent to the host. A pane has no
100
+ * window the pointer is over — the host's window is — so the host shows
101
+ * it (`<Frame>`'s pane box, src/frame/index.js). Core asks only when the
102
+ * cursor changes, so this is a message per change, not per motion.
103
+ */
104
+ setCursor(name) {
105
+ if (this.destroyed) return;
106
+ this.app._paneSend?.({ type: 'pane-cursor', cursor: name ?? null });
107
+ }
108
+
98
109
  requestAnimationFrame(cb) {
99
110
  return this.app._requestFrame(cb);
100
111
  }
package/src/events.js CHANGED
@@ -1322,6 +1322,19 @@ export class EventManager {
1322
1322
  callHandler(node, 'unstable_onAttention', handler, ev);
1323
1323
  }
1324
1324
 
1325
+ /**
1326
+ * Apply the cursor again with no motion: for a node whose cursor changed
1327
+ * under a pointer standing still — a `<Frame>` pane's, which arrives from
1328
+ * the pane's process after the motion that changed it was answered here.
1329
+ * A point cursor is not asked again, as there is no new point: each node
1330
+ * keeps what it last named. Nothing, once the window is gone — a pane's
1331
+ * box lets go of its cursor as it unmounts.
1332
+ */
1333
+ refreshCursor() {
1334
+ if (this.node.destroyed || !this.node.window) return;
1335
+ this._updateCursor(this.hoverPath);
1336
+ }
1337
+
1325
1338
  /** Apply the deepest hovered node's cursor to the window: its `cursor`
1326
1339
  * style, or what it says of the point under the pointer (`cursorAt`), or
1327
1340
  * its `defaultCursor` — and failing all three, its parent's. Asked on
@@ -569,10 +569,20 @@ function PaneHostView({
569
569
  if (msg?.type === 'pane-present') {
570
570
  host.setRect(node.abs);
571
571
  host.present(msg.id);
572
+ } else if (msg?.type === 'pane-cursor') {
573
+ // The pane names its cursor as an element that draws names one for
574
+ // a point: this box is the node the host's pointer is over, so the
575
+ // cursor is its default — a `cursor` style on the <Frame> still
576
+ // wins — and it is shown now, because the motion that changed it
577
+ // was answered in the host before the pane had said
578
+ node.defaultCursor = msg.cursor ?? undefined;
579
+ node.root?.events?.refreshCursor();
572
580
  }
573
581
  });
574
582
  return () => {
575
583
  off();
584
+ node.defaultCursor = undefined;
585
+ node.root?.events?.refreshCursor();
576
586
  node.absolutize = origAbsolutize;
577
587
  if (origShift) node._shiftAbs = origShift;
578
588
  if (s.paneHost === host) s.paneHost = null;
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;
@@ -510,6 +510,8 @@ export class NodeLayout {
510
510
  return;
511
511
  }
512
512
  this.abs = { x, y, width, height };
513
+ const placed = this._absAssigned;
514
+ this._absAssigned = true;
513
515
  // moving or resizing changes where this subtree can be hit, and the
514
516
  // cached unions all the way up with it
515
517
  this._clearHitBounds();
@@ -517,10 +519,16 @@ export class NodeLayout {
517
519
  // the reach `_ownPaintBounds` names, ring and shadow alike: a card
518
520
  // pushed down by a row above it leaves its old shadow on the surface
519
521
  // unless the claim for where it was covers that shadow too
520
- const grow =
521
- Math.max(this._outlineExtent(), this._shadowExtent()) + DAMAGE_SLOP;
522
+ const reach = Math.max(this._outlineExtent(), this._shadowExtent());
523
+ const grow = reach + DAMAGE_SLOP;
522
524
  const shift = layoutDiff.shift;
523
- const had = old.width > 0 && old.height > 0;
525
+ // A rect of no area has no pixels of its own — but an outline or a
526
+ // shadow is drawn round it all the same, and a box of no height
527
+ // arriving with a ring round it claimed nothing, and was never drawn.
528
+ // The old rect counts only once layout has placed the node at all:
529
+ // before that it is the origin, where it never was.
530
+ const had =
531
+ (old.width > 0 && old.height > 0) || (reach > 0 && placed === true);
524
532
  if (shift) {
525
533
  // Riding a blit (issue #398): the rect this node *would* have had if
526
534
  // nothing but the scroll had happened. Landing there is the blit's
@@ -555,7 +563,7 @@ export class NodeLayout {
555
563
  return;
556
564
  }
557
565
  if (had) layoutDiff.sink(insetRect(was, -grow), this);
558
- if (width > 0 && height > 0) {
566
+ if ((width > 0 && height > 0) || reach > 0) {
559
567
  layoutDiff.sink(insetRect(this.abs, -grow), this);
560
568
  }
561
569
  return;
@@ -573,7 +581,7 @@ export class NodeLayout {
573
581
  if (had) {
574
582
  layoutDiff.sink(insetRect(old, -grow), this);
575
583
  }
576
- if (width > 0 && height > 0) {
584
+ if ((width > 0 && height > 0) || reach > 0) {
577
585
  layoutDiff.sink(insetRect(this.abs, -grow), this);
578
586
  }
579
587
  }
@@ -600,6 +608,19 @@ export class NodeLayout {
600
608
  const style = this.style;
601
609
  if (style.backgroundImage) return null;
602
610
  if (style.opacity !== undefined && style.opacity < 1) return null;
611
+ // A radius the box is too small for is drawn at half its shorter side
612
+ // (`roundedCorners`), so a resize that moves that moves all four
613
+ // corners — and their outline and shadow with them — and the ones at the
614
+ // edges that held are in no band along the edges that moved.
615
+ const radius = style.borderRadius ?? 0;
616
+ const now = this.abs;
617
+ if (
618
+ radius > 0 &&
619
+ Math.min(radius, old.width / 2, old.height / 2) !==
620
+ Math.min(radius, now.width / 2, now.height / 2)
621
+ ) {
622
+ return null;
623
+ }
603
624
  const bw = resolveBorderWidths(style, this.direction);
604
625
  const reach =
605
626
  Math.max(
@@ -610,7 +631,6 @@ export class NodeLayout {
610
631
  bw.left,
611
632
  grow,
612
633
  ) + DAMAGE_SLOP;
613
- const now = this.abs;
614
634
  const bands = [];
615
635
  const oldBottom = old.y + old.height;
616
636
  const newBottom = now.y + now.height;
package/src/nodes/node.js CHANGED
@@ -31,7 +31,7 @@ import { dropVisibleSelection, selectionSurfaceOf } from '../textselection.js';
31
31
  import { NodeAnimation } from './animation.js';
32
32
  import { NodeBoxPaint } from './boxpaint.js';
33
33
  import { NodeCascade } from './cascade.js';
34
- import { NO_DAMAGE } from './damage.js';
34
+ import { DAMAGE_SLOP, NO_DAMAGE } from './damage.js';
35
35
  import { NodeHitTest } from './hittest.js';
36
36
  import { installMethods } from './install.js';
37
37
  import { NodeInvalidate } from './invalidate.js';
@@ -40,6 +40,7 @@ import { NodeLayout } from './layout.js';
40
40
  import { NodeLayoutHost } from './layouthost.js';
41
41
  import { NodeMoveBlit } from './moveblit.js';
42
42
  import { NodePaint } from './paint.js';
43
+ import { unionRect } from './rects.js';
43
44
  import { NodePosition } from './position.js';
44
45
  import { NodeQueries } from './queries.js';
45
46
  import { NodeScrollBlit } from './scrollblit.js';
@@ -66,6 +67,9 @@ const NO_SELF_DAMAGED = new Set();
66
67
  * `_nonYogaKids` counts, so the count stays right for a parent that has no
67
68
  * box of its own either. */
68
69
  const outsideYoga = (child) => !child.yoga || child.isWindow;
70
+ // Drawn by the box it is in: a `<text>`'s characters, a span. A popup has no
71
+ // box here either, but draws in a window of its own.
72
+ const isBoxless = (child) => !child.yoga && !child.isPopup;
69
73
 
70
74
  export class Node {
71
75
  get ownerDocument() {
@@ -126,6 +130,8 @@ export class Node {
126
130
  this.destroyed = false;
127
131
  // absolute rect within the owning window, filled by absolutize()
128
132
  this.abs = { x: 0, y: 0, width: 0, height: 0 };
133
+ // whether layout has ever given this node a rect (`_assignAbs`)
134
+ this._absAssigned = false;
129
135
  // node states that style blocks can react to, owned by EventManager
130
136
  this.states = {
131
137
  ':hover': false,
@@ -392,8 +398,9 @@ export class Node {
392
398
  // nothing vacating — the child being added had no pixels — and the
393
399
  // layout diff claims where it lands, so it names no region at all. A
394
400
  // child already here is a move, and says so (`_childListFine`).
395
- const fine = this._childListFine(child.parent !== this);
396
- const before = fine ? null : this._childListBefore();
401
+ const boxless = isBoxless(child);
402
+ const fine = !boxless && this._childListFine(child.parent !== this);
403
+ const before = fine || boxless ? null : this._childListBefore();
397
404
  // a move has to leave the yoga tree too — yoga aborts on insertChild of
398
405
  // a node that still has a parent
399
406
  if (child.parent === this && this._joinsYoga(child)) {
@@ -422,7 +429,8 @@ export class Node {
422
429
  // against the app's, and only now can see the `scale` props above it.
423
430
  child._rescaleSubtree(mounting);
424
431
  this._textContentChanged();
425
- this._childListChanged(before, fine);
432
+ if (boxless) this._boxlessChildChanged();
433
+ else this._childListChanged(before, fine);
426
434
  a11yHooks.attached?.(this, child);
427
435
  }
428
436
 
@@ -535,10 +543,13 @@ export class Node {
535
543
  // captured while the child is still attached, so it covers the rect the
536
544
  // child is about to stop occupying — the child's own, for a viewport
537
545
  // mid-blit, where this node's box is the whole scrolled band
538
- const fine = this._childListFine(true);
539
- const before = fine
540
- ? (child._claimBounds() ?? NO_DAMAGE)
541
- : this._childListBefore();
546
+ const boxless = isBoxless(child);
547
+ const fine = !boxless && this._childListFine(true);
548
+ const before = boxless
549
+ ? null
550
+ : fine
551
+ ? (child._claimBounds() ?? NO_DAMAGE)
552
+ : this._childListBefore();
542
553
  this.children.splice(index, 1);
543
554
  if (outsideYoga(child)) this._nonYogaKids--;
544
555
  if (this._joinsYoga(child)) {
@@ -557,7 +568,30 @@ export class Node {
557
568
  child.yoga = null;
558
569
  }
559
570
  this._textContentChanged();
560
- this._childListChanged(before, fine);
571
+ if (boxless) this._boxlessChildChanged();
572
+ else this._childListChanged(before, fine);
573
+ }
574
+
575
+ /**
576
+ * A child with no box of its own — a `<text>`'s run of characters, or a
577
+ * `<text>` nested in one — arrived or left. It is drawn by the nearest box
578
+ * above it, so what changed is that box's pixels, and no rect: the box
579
+ * claims where it is now and again once layout has run, the way a string
580
+ * that changes in place claims (`TextChunkNode.setText`).
581
+ *
582
+ * Not through `_childListChanged`. Under a scroll pane its claims are the
583
+ * finer ones (`_childListFine`): the leaving child's own rect, and the
584
+ * layout diff's claim of where an arriving one lands. A boxless child has
585
+ * neither, so a label emptied or filled inside a pane claimed nothing, and
586
+ * its old glyphs stayed on the screen. And a span that gains a child has
587
+ * no rect of its own to claim either.
588
+ */
589
+ _boxlessChildChanged() {
590
+ this._clearHitBounds();
591
+ let owner = this;
592
+ while (owner && !owner.yoga) owner = owner.parent;
593
+ if (owner) owner._invalidateLayout('text');
594
+ else this.root?.invalidate(true, null, 'text');
561
595
  }
562
596
 
563
597
  /** Destroy real resources (X windows) in this subtree. Yoga nodes are
@@ -623,7 +657,10 @@ export class Node {
623
657
  // factor. The walk restyles this node too, so the call after it hits
624
658
  // the identity check and costs nothing twice.
625
659
  if (newProps.scale !== prev.scale) this._rescaleSubtree();
660
+ // asked of the style still in force: see `_clipStarted`
661
+ const clipped = this.clipsChildren();
626
662
  const style = this._syncStyle(newProps);
663
+ if (!clipped && this.clipsChildren()) this._clipStarted();
627
664
  let layoutChanged = false;
628
665
  // hoisted styles hit the identity check and skip the whole update
629
666
  if (this.yoga && style !== prevStyle) {
@@ -696,6 +733,34 @@ export class Node {
696
733
  a11yHooks.propsChanged?.(this);
697
734
  }
698
735
 
736
+ /**
737
+ * This node started clipping what it holds (`overflow: 'hidden'` or
738
+ * `'scroll'`), so whatever its children drew past its box is gone, and
739
+ * nothing moved for the layout diff to notice. Its own claims are made
740
+ * after the new style is in, when its paint bounds already stop at its
741
+ * box, so the old reach is claimed here: what the children drew, which is
742
+ * theirs, and the same whether or not this node clips them.
743
+ *
744
+ * The other way — a box that stops clipping — needs nothing of its own:
745
+ * the claim once layout has run reaches as far as the children now do.
746
+ */
747
+ _clipStarted() {
748
+ let reach = null;
749
+ for (const child of this.children) {
750
+ if (child.isWindow || !child.yoga || child.hidden) continue;
751
+ if (child.style?.display === 'none') continue;
752
+ const bounds = child._subtreeBounds();
753
+ reach = reach ? unionRect(reach, bounds) : bounds;
754
+ }
755
+ if (!reach) return;
756
+ this.invalidate(false, {
757
+ x: reach.x - DAMAGE_SLOP,
758
+ y: reach.y - DAMAGE_SLOP,
759
+ width: reach.width + 2 * DAMAGE_SLOP,
760
+ height: reach.height + 2 * DAMAGE_SLOP,
761
+ });
762
+ }
763
+
699
764
  /**
700
765
  * Did anything this node *draws* change? Answering true damages the whole
701
766
  * node; answering false contributes no damage at all.
@@ -355,11 +355,15 @@ export class NodePaint {
355
355
  }
356
356
 
357
357
  /** `order` painted in turn, each child culled against the window, its
358
- * clipping ancestors and the pass. */
358
+ * clipping ancestors and the pass — by what it paints, not by its box: a
359
+ * row scrolled just out of a pane still reaches into it with whatever
360
+ * hangs off it, a descender past a trimmed line box or a child that
361
+ * overflows it, and culled by its box it drew none of that in a full
362
+ * repaint while a scroll's copy carried it. */
359
363
  _paintChildList(ctx, order) {
360
364
  for (const child of order) {
361
365
  if (child._promoted) continue; // on a layer of its own: a hole here
362
- if (child._offscreen(child.abs, DAMAGE_SLOP)) continue;
366
+ if (child._offscreen(child._subtreeBounds(), DAMAGE_SLOP)) continue;
363
367
  if (child._outsideDamage()) continue;
364
368
  child.paint(ctx);
365
369
  }
@@ -371,8 +375,9 @@ export class NodePaint {
371
375
  */
372
376
  _paintsInto(rect) {
373
377
  if (this._promoted) return false;
374
- if (!rectsOverlap(this._subtreeBounds(), rect, DAMAGE_SLOP)) return false;
375
- return !this._offscreen(this.abs, DAMAGE_SLOP);
378
+ const reach = this._subtreeBounds();
379
+ if (!rectsOverlap(reach, rect, DAMAGE_SLOP)) return false;
380
+ return !this._offscreen(reach, DAMAGE_SLOP);
376
381
  }
377
382
 
378
383
  /**
@@ -66,6 +66,12 @@ export function scrollbarGeometry({
66
66
  // carries `scale` so the track, the hit slop and the thumb's corner
67
67
  // radius downstream draw from the same number.
68
68
  const barWidth = SCROLLBAR_WIDTH * scale;
69
+ // …and never wider than the pane across: a pane thinner than the bar and
70
+ // its inset shows none. Laid a bar's width in from its far edge, the bar
71
+ // of a pane four pixels tall stood four pixels above it, painted past the
72
+ // pane's box where none of the pane's claims reach — the same stray ink as
73
+ // a thumb run past its track, below, the other way round.
74
+ if (crossSize < barWidth + inset) return null;
69
75
  const rtl = direction === 'rtl';
70
76
  // Never longer than the track: a pane shorter than the minimum gets a
71
77
  // thumb that fills it and has no travel. The bar paints outside the
package/src/nodes/text.js CHANGED
@@ -9,8 +9,9 @@ import {
9
9
  import { cssColorStraight } from 'ntk/color';
10
10
  import { hooks as a11yHooks } from '../a11y.js';
11
11
  import { codePointAtOffset, codeUnitOffsets } from '../textrange.js';
12
- import { NO_DAMAGE } from './damage.js';
12
+ import { DAMAGE_SLOP, NO_DAMAGE, layoutDiff } from './damage.js';
13
13
  import { Node } from './node.js';
14
+ import { insetRect, unionRect } from './rects.js';
14
15
 
15
16
  /**
16
17
  * Yoga's measure modes, in words. Indexed by the integer yoga hands a
@@ -37,6 +38,17 @@ const STRIP_COVERAGE = 0.45;
37
38
 
38
39
  const textStripBelow = new WeakMap();
39
40
 
41
+ /** `ink`, when any of it lies outside `box`; else null. */
42
+ function outside(ink, box) {
43
+ if (!ink) return null;
44
+ return ink.x < box.x ||
45
+ ink.y < box.y ||
46
+ ink.x + ink.width > box.x + box.width ||
47
+ ink.y + ink.height > box.y + box.height
48
+ ? ink
49
+ : null;
50
+ }
51
+
40
52
  /** `TextNode._trim`'s answer, per layout. */
41
53
  const trims = new WeakMap();
42
54
 
@@ -186,6 +198,8 @@ export class TextNode extends Node {
186
198
  super('text', props, app, { yoga: !span });
187
199
  this.isSpan = span;
188
200
  this._layouts = new Map();
201
+ // how far the ink of the last paint reached past the box (`_keepInkReach`)
202
+ this._inkReach = null;
189
203
  }
190
204
 
191
205
  /** Height for a width: the paragraph shaped into whatever is on offer.
@@ -217,6 +231,8 @@ export class TextNode extends Node {
217
231
  return;
218
232
  }
219
233
  this._layouts.clear();
234
+ // the ink goes with the layout it was placed from (`_ownPaintBounds`)
235
+ this._clearPaintBounds();
220
236
  if (this.yoga) this.yoga.markDirty();
221
237
  }
222
238
 
@@ -236,6 +252,7 @@ export class TextNode extends Node {
236
252
  return;
237
253
  }
238
254
  this._layouts.clear();
255
+ this._clearPaintBounds();
239
256
  }
240
257
 
241
258
  /**
@@ -408,7 +425,10 @@ export class TextNode extends Node {
408
425
  return this._elides() ? maxWidth : Infinity;
409
426
  }
410
427
 
411
- _layoutFor(maxWidth) {
428
+ /** The layout at `maxWidth`: kept, or shaped now. With `shape` false, only
429
+ * a kept one — for a question that must not cost a shaping, such as how
430
+ * far a paragraph not yet painted will ink (`_inkPlaced`). */
431
+ _layoutFor(maxWidth, shape = true) {
412
432
  const fonts = this.app?.fonts;
413
433
  if (!fonts) return null; // mock container in tests: no text metrics
414
434
  const maxLines = this._maxLines();
@@ -435,6 +455,7 @@ export class TextNode extends Node {
435
455
  layout = this._shapedWider(maxWidth, maxLines, overflow);
436
456
  if (layout) this._layouts.set(maxWidth, { layout, maxLines, overflow });
437
457
  }
458
+ if (!layout && !shape) return null;
438
459
  if (!layout) {
439
460
  const spans = this.collectSpans([]);
440
461
  const base = this.resolvedTextStyle();
@@ -585,9 +606,12 @@ export class TextNode extends Node {
585
606
  * a differently-placed layout is a caret in the wrong place, and nothing
586
607
  * about it would look like a bug in this function.
587
608
  */
588
- _placedLayout() {
609
+ _placedLayout(shape = true) {
589
610
  const content = this.contentBox();
590
- const layout = this._layoutFor(this._wrapWidth(content.width || Infinity));
611
+ const layout = this._layoutFor(
612
+ this._wrapWidth(content.width || Infinity),
613
+ shape,
614
+ );
591
615
  if (!layout) return null;
592
616
  // the box was shortened from the top, so the glyphs come up with it
593
617
  const trim = this._trim(layout);
@@ -641,6 +665,7 @@ export class TextNode extends Node {
641
665
  paintContent(ctx) {
642
666
  const placed = this._placedLayout();
643
667
  if (!placed) return;
668
+ this._keepInkReach(placed);
644
669
  this._paintSelection(ctx);
645
670
  if (this._paintsStrip()) {
646
671
  this._paintStrip(ctx, placed);
@@ -649,6 +674,146 @@ export class TextNode extends Node {
649
674
  placed.layout.draw(ctx, placed.x, placed.y);
650
675
  }
651
676
 
677
+ /**
678
+ * Where this paragraph puts ink, when that is outside its box: the box
679
+ * grown by the ink it has on the screen now, before a layout pass, and by
680
+ * the ink the frame is about to draw, after one.
681
+ *
682
+ * The glyphs are drawn where the layout puts them, and two things put them
683
+ * past the box. A word too wide for the box and with nowhere to break is
684
+ * drawn whole, across the box's edge. And a box trimmed to its capitals
685
+ * (`textBoxTrim`) leaves every descender and every ascender taller than a
686
+ * capital outside it, by design. A change that repaints the paragraph —
687
+ * its colour, its characters — claimed the box, so the ink outside it kept
688
+ * the old colour, or stayed after the word was gone.
689
+ */
690
+ _ownPaintBounds() {
691
+ let bounds = super._ownPaintBounds();
692
+ // Both, whenever it is asked. A claim made before this frame's layout
693
+ // wants the ink on the screen, one made after it the ink about to be
694
+ // drawn — and so does everything the paint sizes from these bounds,
695
+ // which asks before this paragraph has ever drawn: an ancestor's
696
+ // `opacity` draws the subtree into a surface this big, and one sized
697
+ // before the ink was known cut a word's overflow off at the box, on the
698
+ // first frame only. The union is never smaller than either.
699
+ const shown = this._inkOnScreen();
700
+ if (shown) bounds = unionRect(bounds, shown);
701
+ const placed = this._inkPlaced();
702
+ if (placed) bounds = unionRect(bounds, placed);
703
+ return bounds;
704
+ }
705
+
706
+ /**
707
+ * A paragraph that moved claims its ink past its box, where it was and
708
+ * where it lands, beside the claims the box makes (`Node._assignAbs`).
709
+ *
710
+ * Those are the box's rect, grown by its outline and shadow, and a rect
711
+ * of no area claims nothing — there were, or will be, no pixels there. A
712
+ * paragraph squeezed to no height by the row it sits in still draws its
713
+ * lines, though, below a box of none: moved along by a sibling, it left
714
+ * its old glyphs on the screen and drew its new ones nowhere.
715
+ */
716
+ _assignAbs(x, y, width, height) {
717
+ const old = this.abs;
718
+ const shown = this._inkOnScreen();
719
+ super._assignAbs(x, y, width, height);
720
+ if (this.abs === old || !layoutDiff.sink) return;
721
+ const shift = layoutDiff.shift;
722
+ // riding a blit: the ink moved with the pixels, and claims nothing
723
+ if (
724
+ shift &&
725
+ old.x + shift.x === x &&
726
+ old.y + shift.y === y &&
727
+ old.width === width &&
728
+ old.height === height
729
+ ) {
730
+ return;
731
+ }
732
+ if (shown) {
733
+ const was = shift
734
+ ? { ...shown, x: shown.x + shift.x, y: shown.y + shift.y }
735
+ : shown;
736
+ layoutDiff.sink(insetRect(was, -DAMAGE_SLOP), this);
737
+ }
738
+ const placed = this._inkPlaced();
739
+ if (placed) layoutDiff.sink(insetRect(placed, -DAMAGE_SLOP), this);
740
+ }
741
+
742
+ /** The rect the lines of a placed layout cover, in window coordinates. */
743
+ _inkOf({ layout, x, y }) {
744
+ const lines = layout.lines;
745
+ if (!lines?.length) return null;
746
+ let left = Infinity;
747
+ let right = -Infinity;
748
+ for (const line of lines) {
749
+ left = Math.min(left, line.x);
750
+ right = Math.max(right, line.x + line.width);
751
+ }
752
+ return {
753
+ x: x + left,
754
+ y,
755
+ width: right - left,
756
+ height: layout.height,
757
+ };
758
+ }
759
+
760
+ /** The ink the frame will draw, where it reaches past the box. */
761
+ _inkPlaced() {
762
+ // a paragraph not laid out yet has no box to be placed in
763
+ const box = this.abs;
764
+ if (!(box.width > 0 || box.height > 0)) return null;
765
+ // Shaped only once this frame's layout has run, when the paint is about
766
+ // to need the very same layout: before it, a claim at the commit would
767
+ // shape every string a batch of input passes through, painted or not.
768
+ // The measure pass does not always make the one the paint draws — a
769
+ // box of no width is drawn unwrapped — and a paragraph moved into one
770
+ // claimed none of its new ink.
771
+ const placed = this._placedLayout(this.root?._laidOut === true);
772
+ return placed ? outside(this._inkOf(placed), box) : null;
773
+ }
774
+
775
+ /** The ink on the screen, where it reaches past the box: kept by the last
776
+ * paint as distances from the box's edges, so it rides a scroll's shift
777
+ * with the box. */
778
+ _inkOnScreen() {
779
+ const reach = this._inkReach;
780
+ if (!reach) return null;
781
+ const box = this.abs;
782
+ return {
783
+ x: box.x - reach.left,
784
+ y: box.y - reach.top,
785
+ width: box.width + reach.left + reach.right,
786
+ height: box.height + reach.top + reach.bottom,
787
+ };
788
+ }
789
+
790
+ /** Remember how far past the box this paint's ink reaches. A reach that
791
+ * changed is a changed paint bound, so the cached ones above it go. */
792
+ _keepInkReach(placed) {
793
+ const ink = outside(this._inkOf(placed), this.abs);
794
+ const box = this.abs;
795
+ const reach = ink && {
796
+ left: Math.max(0, box.x - ink.x),
797
+ top: Math.max(0, box.y - ink.y),
798
+ right: Math.max(0, ink.x + ink.width - (box.x + box.width)),
799
+ bottom: Math.max(0, ink.y + ink.height - (box.y + box.height)),
800
+ };
801
+ const was = this._inkReach;
802
+ if (
803
+ reach === was ||
804
+ (reach &&
805
+ was &&
806
+ reach.left === was.left &&
807
+ reach.top === was.top &&
808
+ reach.right === was.right &&
809
+ reach.bottom === was.bottom)
810
+ ) {
811
+ return;
812
+ }
813
+ this._inkReach = reach;
814
+ this._clearPaintBounds();
815
+ }
816
+
652
817
  /**
653
818
  * Whether this paragraph is too small to read, and is painted as a strip
654
819
  * where its lines are instead of as glyphs (`_paintStrip`).
package/src/win32/app.js CHANGED
@@ -416,7 +416,7 @@ export class Win32App {
416
416
 
417
417
  /**
418
418
  * The pane process's end of the frame channel: geometry and input in,
419
- * presents out. Feature-detected by the pane bootstrap
419
+ * presents and the cursor out. Feature-detected by the pane bootstrap
420
420
  * (src/frame/childmain.js), and a no-op in a process that is not a pane.
421
421
  */
422
422
  attachPaneChannel(channel) {
@@ -205,6 +205,15 @@ export class Win32PaneWindow {
205
205
  this.title = title;
206
206
  }
207
207
 
208
+ /** The cursor this pane's tree names, sent to the host, which shows it:
209
+ * the pointer is over the host's window, not this one (the Cocoa pane's
210
+ * `setCursor`). */
211
+ setCursor(name) {
212
+ this.cursor = name;
213
+ if (this.destroyed) return;
214
+ this.app._paneSend?.({ type: 'pane-cursor', cursor: name ?? null });
215
+ }
216
+
208
217
  setSizeHints() {}
209
218
 
210
219
  /** The host sizes a pane, not the pane itself: a `<Frame>`'s box is laid