@react-x11/components 0.7.1 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (37) hide show
  1. package/README.md +214 -31
  2. package/dist/color-picker/ColorPicker.d.ts.map +1 -1
  3. package/dist/color-picker/ColorPicker.js +43 -33
  4. package/dist/color-picker/ColorPicker.js.map +1 -1
  5. package/dist/internal/scroll.d.ts.map +1 -1
  6. package/dist/internal/scroll.js +32 -15
  7. package/dist/internal/scroll.js.map +1 -1
  8. package/dist/internal/units.d.ts +2 -0
  9. package/dist/internal/units.d.ts.map +1 -0
  10. package/dist/internal/units.js +35 -0
  11. package/dist/internal/units.js.map +1 -0
  12. package/dist/internal/window.d.ts.map +1 -1
  13. package/dist/internal/window.js +4 -2
  14. package/dist/internal/window.js.map +1 -1
  15. package/dist/table/index.d.ts.map +1 -1
  16. package/dist/table/index.js +14 -5
  17. package/dist/table/index.js.map +1 -1
  18. package/dist/terminal/vt/node.d.ts +56 -3
  19. package/dist/terminal/vt/node.d.ts.map +1 -1
  20. package/dist/terminal/vt/node.js +106 -26
  21. package/dist/terminal/vt/node.js.map +1 -1
  22. package/dist/terminal/vt/renderer.d.ts +4 -0
  23. package/dist/terminal/vt/renderer.d.ts.map +1 -1
  24. package/dist/terminal/vt/renderer.js +14 -5
  25. package/dist/terminal/vt/renderer.js.map +1 -1
  26. package/dist/tree/index.d.ts.map +1 -1
  27. package/dist/tree/index.js +6 -4
  28. package/dist/tree/index.js.map +1 -1
  29. package/package.json +3 -3
  30. package/src/color-picker/ColorPicker.ts +107 -81
  31. package/src/internal/scroll.ts +34 -15
  32. package/src/internal/units.ts +34 -0
  33. package/src/internal/window.ts +4 -2
  34. package/src/table/index.ts +14 -5
  35. package/src/terminal/vt/node.ts +128 -29
  36. package/src/terminal/vt/renderer.ts +27 -5
  37. package/src/tree/index.ts +6 -4
@@ -44,6 +44,7 @@ import type { DrawnNode, ScrollableNode } from 'react-x11';
44
44
  import type { RowHeights, RowKey } from './heights.js';
45
45
  import { afterLayout, cancelAfterLayout } from './timers.js';
46
46
  import type { LayoutTick } from './timers.js';
47
+ import { scaleOf } from './units.js';
47
48
 
48
49
  /** All a reveal needs of a row: `<TreeRow>` and `<TableRow>` are both this. */
49
50
  interface Keyed {
@@ -148,13 +149,17 @@ export function useReveal(sources: RevealSources): Reveal {
148
149
  const scrollTo = useCallback((y: number): void => {
149
150
  const box = src.current.box.current;
150
151
  if (!box) return;
152
+ // `y` is logical, like everything the reveal computes with; the raw
153
+ // node properties are device (../internal/units.ts), divided here so the
154
+ // clamp and the early-out speak the caller's unit.
155
+ const s = scaleOf(box);
151
156
  // Clamped here rather than left to the container, so what comes back on
152
157
  // `onScroll` is the number that was asked for and can be recognised.
153
158
  const to = Math.min(
154
159
  Math.max(0, y),
155
- Math.max(0, box.contentHeight - box.abs.height),
160
+ Math.max(0, (box.contentHeight - box.abs.height) / s),
156
161
  );
157
- if (to === box.scrollY) return;
162
+ if (to === box.scrollY / s) return;
158
163
  asked.current = to;
159
164
  box.scrollTo({ y: to });
160
165
  }, []);
@@ -166,9 +171,10 @@ export function useReveal(sources: RevealSources): Reveal {
166
171
  const box = src.current.box.current;
167
172
  const want = owedShift.current;
168
173
  if (!box || want === 0) return;
169
- const was = box.scrollY;
174
+ const s = scaleOf(box);
175
+ const was = box.scrollY / s;
170
176
  scrollTo(was + want);
171
- owedShift.current = want - (box.scrollY - was);
177
+ owedShift.current = want - (box.scrollY / s - was);
172
178
  }, [scrollTo]);
173
179
 
174
180
  const nudge = useCallback(
@@ -208,7 +214,10 @@ export function useReveal(sources: RevealSources): Reveal {
208
214
  settled.current = null;
209
215
  return;
210
216
  }
211
- const viewport = box.abs.height;
217
+ // Logical, like the height index the unmounted arithmetic mixes with —
218
+ // the raw properties are device (../internal/units.ts).
219
+ const s = scaleOf(box);
220
+ const viewport = box.abs.height / s;
212
221
  if (viewport <= 0) return; // nothing laid out to scroll inside yet
213
222
 
214
223
  // How far the row is outside the pane, in pixels of scrolling. The row's
@@ -223,16 +232,20 @@ export function useReveal(sources: RevealSources): Reveal {
223
232
  : null;
224
233
  const placed = mounted
225
234
  ? {
226
- above: box.abs.y - mounted.abs.y,
227
- below: mounted.abs.y + mounted.abs.height - (box.abs.y + viewport),
228
- height: mounted.abs.height,
235
+ above: (box.abs.y - mounted.abs.y) / s,
236
+ below:
237
+ (mounted.abs.y +
238
+ mounted.abs.height -
239
+ (box.abs.y + box.abs.height)) /
240
+ s,
241
+ height: mounted.abs.height / s,
229
242
  }
230
243
  : {
231
- above: box.scrollY - heights.offsetAt(at),
244
+ above: box.scrollY / s - heights.offsetAt(at),
232
245
  below:
233
246
  heights.offsetAt(at) +
234
247
  heights.heightAt(at) -
235
- (box.scrollY + viewport),
248
+ (box.scrollY / s + viewport),
236
249
  height: heights.heightAt(at),
237
250
  };
238
251
  /**
@@ -254,8 +267,8 @@ export function useReveal(sources: RevealSources): Reveal {
254
267
  */
255
268
  let certain = mounted !== null || heights.isMeasured(at);
256
269
  if (certain && heights.hasMeasurements()) {
257
- const from = Math.min(at, heights.indexAt(box.scrollY));
258
- const to = Math.max(at, heights.indexAt(box.scrollY + viewport));
270
+ const from = Math.min(at, heights.indexAt(box.scrollY / s));
271
+ const to = Math.max(at, heights.indexAt(box.scrollY / s + viewport));
259
272
  for (let i = from; i <= to && certain; i++) {
260
273
  certain = heights.isMeasured(i);
261
274
  }
@@ -283,8 +296,10 @@ export function useReveal(sources: RevealSources): Reveal {
283
296
  }
284
297
  // A move the pane cannot make yet leaves the debt standing: the layout
285
298
  // that admits the rows just appended is the one that will let it finish.
299
+ // `was` in device, compared against the same raw property — whether it
300
+ // moved is unit-free; the request itself goes through logical scrollTo.
286
301
  const was = box.scrollY;
287
- scrollTo(was + move);
302
+ scrollTo(was / s + move);
288
303
  if (box.scrollY !== was) {
289
304
  stuck.current = null; // it moved; the scroll it caused brings us back
290
305
  return;
@@ -323,8 +338,12 @@ export function useReveal(sources: RevealSources): Reveal {
323
338
 
324
339
  const heard = useCallback((scrollY: number): void => {
325
340
  // A scroll this component did not ask for ends the whole chase, not just
326
- // the outstanding half of it.
327
- if (scrollY !== asked.current) {
341
+ // the outstanding half of it. Within half a pixel is ours: the payload
342
+ // is the device offset divided back to logical, and at a fractional
343
+ // scale that round trip need not be bit-identical to what was asked.
344
+ const ours =
345
+ asked.current !== null && Math.abs(scrollY - asked.current) < 0.5;
346
+ if (!ours) {
328
347
  owed.current = null;
329
348
  settled.current = null;
330
349
  owedShift.current = 0;
@@ -0,0 +1,34 @@
1
+ // One logical pixel is `scale` device pixels — react-x11's docs/scale.md.
2
+ //
3
+ // **Shared between `<Tree>` and `<Table>`** like the rest of this directory;
4
+ // see the header of `./heights.ts` for why it is internal rather than a
5
+ // shared module with a subpath.
6
+ //
7
+ // The virtualizers keep their whole model in logical pixels: style lengths,
8
+ // the `onScroll`/`onViewport` payloads, the height index, and the props
9
+ // (`rowHeight`, `estimatedRowHeight`) are all logical. But the **raw node
10
+ // properties** they read back between events — `scrollY`, `contentHeight`,
11
+ // `abs` — are device pixels. At scale 1 the two units coincide, which is how
12
+ // every harness test and every Xvfb check passed while a retina display
13
+ // doubled every raw read: the offset re-read after layout landed at twice
14
+ // the logical value, the slice was rebuilt a viewport below where the user
15
+ // was looking, and the table example went blank on a flick (and stayed
16
+ // blank after an append-and-reveal). Every raw read goes through this
17
+ // divisor so the arithmetic stays in one unit.
18
+ //
19
+ // **A pointer is the other half of this.** An event's `x`/`y` are logical,
20
+ // so a gesture measured against a laid-out box wants the box in that unit:
21
+ // `node.getClientRects()[0]`, which is `abs` already divided, rather than
22
+ // `abs` and a multiply. `<ColorPicker>` compared the two directly and put
23
+ // its thumb a quarter of the way from the corner on every retina panel.
24
+ // Reach for `scaleOf` where there is no rect to ask for — a raw `scrollY`,
25
+ // a `contentHeight`, a constant that never passes through a style.
26
+ //
27
+ // `scale` is on every retained node at run time but not on the public
28
+ // `DrawnNode` type — hence the structural read, and `object` rather than
29
+ // `{ scale?: unknown }` in the signature: a weak type with no property in
30
+ // common with `DrawnNode` fails TS2559 at every call site.
31
+ export function scaleOf(node: object | null | undefined): number {
32
+ const s = node ? (node as { scale?: unknown }).scale : undefined;
33
+ return typeof s === 'number' && s > 0 ? s : 1;
34
+ }
@@ -48,6 +48,7 @@ import type { ScrollableNode } from 'react-x11';
48
48
  import type { RowHeights, RowKey } from './heights.js';
49
49
  import { cancelLater, later } from './timers.js';
50
50
  import type { DelayTick } from './timers.js';
51
+ import { scaleOf } from './units.js';
51
52
 
52
53
  /** All this needs of a row — `TreeRow` and `TableRow` are both this. */
53
54
  interface Keyed {
@@ -298,7 +299,6 @@ export function useVirtualWindow(inputs: VirtualWindowInputs): VirtualWindow {
298
299
  // one has no slice to rebuild, and re-rendering it on a scroll it
299
300
  // already drew would be work for nothing.
300
301
  if (!inp.current.virtualizing) return;
301
- (globalThis as any).__scrolls = ((globalThis as any).__scrolls ?? 0) + 1;
302
302
  const now = Date.now();
303
303
  const s = vel.current;
304
304
  const dt = now - s.t;
@@ -332,7 +332,9 @@ export function useVirtualWindow(inputs: VirtualWindowInputs): VirtualWindow {
332
332
  const { box, virtualizing } = inp.current;
333
333
  const node = box.current;
334
334
  if (!node || !virtualizing) return;
335
- const y = node.scrollY;
335
+ // The raw property is device pixels; the viewport model — like the
336
+ // `onScroll` payload `scrolled` is fed — is logical (../internal/units.ts).
337
+ const y = node.scrollY / scaleOf(node);
336
338
  setView((prev) => (prev.top === y ? prev : { ...prev, top: y }));
337
339
  }, []);
338
340
 
@@ -70,6 +70,7 @@ import {
70
70
  cancelLater,
71
71
  later,
72
72
  } from '../internal/timers.js';
73
+ import { scaleOf } from '../internal/units.js';
73
74
  import type { DelayTick } from '../internal/timers.js';
74
75
  import { useReveal } from '../internal/scroll.js';
75
76
  import {
@@ -953,7 +954,10 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
953
954
  const syncScroll = useCallback((): void => {
954
955
  const box = body.current;
955
956
  if (!box) return;
956
- const x = box.scrollX;
957
+ // Device on the raw property; the header margin it feeds is a style
958
+ // length, logical — the same division every raw read here makes
959
+ // (../internal/units.ts).
960
+ const x = box.scrollX / scaleOf(box);
957
961
  setScrollX((prev) => (prev === x ? prev : x));
958
962
  winSync();
959
963
  }, [winSync]);
@@ -992,13 +996,18 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
992
996
  ).total;
993
997
  const box = body.current;
994
998
  const rows = orderedRef.current;
995
- const anchor = box ? heights.indexAt(box.scrollY) : 0;
999
+ const anchor = box ? heights.indexAt(box.scrollY / scaleOf(box)) : 0;
996
1000
  let shift = 0;
997
1001
  let changed = false;
998
1002
  for (const [id, { node, at }] of rowNodes.current) {
999
1003
  if (rows[at]?.id !== id) continue; // drawn against a list that moved
1000
- if (node.abs.width !== expected) continue;
1001
- const height = node.abs.height;
1004
+ // `abs` is device; the resolved widths and the height index are
1005
+ // logical. Within a pixel rather than exact: a fractional scale
1006
+ // rounds the device width to the pixel grid, and the stale-width
1007
+ // layouts this gate exists for are whole columns off, not fractions.
1008
+ const s = scaleOf(node);
1009
+ if (Math.abs(node.abs.width / s - expected) >= 1) continue;
1010
+ const height = node.abs.height / s;
1002
1011
  const was = heights.heightAt(at);
1003
1012
  if (!heights.measure(id, at, height)) continue;
1004
1013
  changed = true;
@@ -1026,7 +1035,7 @@ export function Table<Row = any>(props: TableProps<Row>): ReactElement {
1026
1035
  if (uniform || !virtualizing) return false;
1027
1036
  const box = body.current;
1028
1037
  if (!box) return false;
1029
- const anchor = heights.indexAt(box.scrollY);
1038
+ const anchor = heights.indexAt(box.scrollY / scaleOf(box));
1030
1039
  const before = heights.offsetAt(anchor);
1031
1040
  if (!heights.adapt()) return false;
1032
1041
  reveal.nudge(heights.offsetAt(anchor) - before);
@@ -15,7 +15,7 @@ import type {
15
15
  MeasureConstraints,
16
16
  MeasuredSize,
17
17
  } from 'react-x11/node';
18
- import type { KeyboardEvent, MouseEvent, WheelEvent } from 'react-x11';
18
+ import type { KeyboardEvent, MouseEvent, Rect, WheelEvent } from 'react-x11';
19
19
  import type { Style } from 'react-x11/style';
20
20
  import { Surface } from 'react-x11/ntk';
21
21
  import {
@@ -127,6 +127,21 @@ interface ClipboardLike {
127
127
 
128
128
  type Grid = { col: number; row: number };
129
129
 
130
+ /** Where the grid is drawn and how big it is — `_gridGeometry`. */
131
+ interface GridGeometry {
132
+ /** The top-left corner, in window coordinates, on whole pixels. */
133
+ x: number;
134
+ y: number;
135
+ cols: number;
136
+ rows: number;
137
+ /** `cols * cellWidth` and `rows * cellHeight`. */
138
+ width: number;
139
+ height: number;
140
+ /** Whether the whole grid lies inside the content box — the condition
141
+ * under which it can be promised opaque. */
142
+ fits: boolean;
143
+ }
144
+
130
145
  export class VtTermNode extends Node {
131
146
  private _term: XtermTerminal | null = null;
132
147
  private _disposables: XtermDisposable[] = [];
@@ -195,9 +210,11 @@ export class VtTermNode extends Node {
195
210
  this._paletteKey = key;
196
211
  this._palette = buildPalette(paletteColors(props));
197
212
  // The palette generation is a signature input, so the ordinary diff
198
- // repaints every cell whose colours actually moved. Nothing else here
199
- // needs to know a theme changed.
200
- this._repaint();
213
+ // repaints every cell whose colours actually moved. The one thing
214
+ // outside the diff's reach is the padding ring around the grid — the
215
+ // node's own background, and outside the rect `_repaint` claims — so
216
+ // this is the claim that covers the whole node.
217
+ this._repaintAll('props');
201
218
  }
202
219
 
203
220
  const { family, size } = this._fontStyle();
@@ -205,7 +222,7 @@ export class VtTermNode extends Node {
205
222
  this._fonts = null;
206
223
  this._mirror.invalidate();
207
224
  this.invalidateMeasure('measure');
208
- this._repaint();
225
+ this._repaintAll('props');
209
226
  }
210
227
  if (props.cursorBlink !== (prev.cursorBlink as boolean | undefined)) {
211
228
  this._syncBlink();
@@ -224,7 +241,17 @@ export class VtTermNode extends Node {
224
241
  // --- the emulator --------------------------------------------------------
225
242
 
226
243
  /**
227
- * Subscribe to everything that means "the screen may look different".
244
+ * Subscribe to what means "the screen may look different".
245
+ *
246
+ * One event carries every change program output can make: `onWriteParsed`
247
+ * fires once per parsed batch, after the text, the scroll and the cursor
248
+ * move it caused have all landed in the buffer. `onScroll` fires once per
249
+ * scrolled *line* — a 300,000-line flood was 336,000 claims — and
250
+ * `onCursorMove` once per parse, and each only ever announced what
251
+ * `onWriteParsed` was about to; core coalesces the claims, but at 2% of a
252
+ * flood's wall time they were the untidiest thing in it. The scrolls this
253
+ * node performs itself — `handleWheel`, `scrollLines`, `_typed`, a drag
254
+ * past an edge — repaint on their own.
228
255
  *
229
256
  * Only render-relevant events: the component keeps `onData`, `onTitleChange`
230
257
  * and `onBell`, which are about the process rather than about pixels.
@@ -234,14 +261,14 @@ export class VtTermNode extends Node {
234
261
  this._term = term;
235
262
  this._mirror.invalidate();
236
263
  if (!term) return;
237
- const dirty = (): void => this._repaint();
238
264
  this._disposables.push(
239
- term.onWriteParsed(dirty),
240
- term.onScroll(dirty),
241
- term.onCursorMove(dirty),
265
+ term.onWriteParsed(() => this._repaint()),
242
266
  term.onResize(() => {
243
267
  this._mirror.invalidate();
244
- this._repaint();
268
+ // The grid may have changed shape, and the strip a smaller one no
269
+ // longer covers owes its background — a fill that a claim inside
270
+ // the opaque rect would skip.
271
+ this._repaintAll('text');
245
272
  }),
246
273
  term.buffer.onBufferChange(() => {
247
274
  // The alternate screen is a different buffer with a different
@@ -451,18 +478,95 @@ export class VtTermNode extends Node {
451
478
 
452
479
  // --- painting ------------------------------------------------------------
453
480
 
481
+ /**
482
+ * The screen may look different: claim the grid.
483
+ *
484
+ * The grid rect rather than a tighter one, because which cells are dirty
485
+ * is not known until the diff runs inside paint — damage is a clip and a
486
+ * cull bound rather than a promise to redraw everything, and the diff still
487
+ * decides what is *rendered*, which is where the cost is. And the rect
488
+ * rather than the node: a node claim is inflated by a pixel of slop that
489
+ * lies outside `opaqueRect()`, so the fills core skips under an opaque
490
+ * pass — the window's background and this node's own, 0.7ms of a 6ms
491
+ * Cocoa frame — would be painted after all. Before the first paint there
492
+ * is no cover to promise and the node is the claim.
493
+ */
454
494
  private _repaint(): void {
455
495
  if (this.destroyed) return;
456
- // The grid rect, not a tighter one: which cells are dirty is not known
457
- // until the diff runs inside paint. Damage is a clip and a cull bound
458
- // rather than a promise to redraw everything, and the diff still decides
459
- // what is *rendered* — which is where the cost is.
460
- this.invalidate(false, this, 'text');
496
+ this.invalidate(false, this.opaqueRect() ?? this, 'text');
497
+ }
498
+
499
+ /** Everything, ring included: the palette or the grid's shape changed. */
500
+ private _repaintAll(reason: 'props' | 'text'): void {
501
+ if (this.destroyed) return;
502
+ this.invalidate(false, this, reason);
503
+ }
504
+
505
+ /**
506
+ * Where the grid goes and how big it is, in window coordinates.
507
+ *
508
+ * The content box's corner and as many whole cells as fit both the box and
509
+ * the emulator — which resizes a beat later, through the component and off
510
+ * the paint stack, so until it catches up the grid is the intersection.
511
+ * The corner is snapped to whole pixels: cells are integers, the composite
512
+ * is a copy of whole pixels, and a half-pixel of drift against the box is
513
+ * invisible where a resampled composite is not. `fits` is whether those
514
+ * cells lie inside the box on whole pixels, which is the condition for
515
+ * promising them opaque; a box smaller than one cell still draws one,
516
+ * clipped, and promises nothing.
517
+ */
518
+ private _gridGeometry(fonts: FontSet, term: XtermTerminal): GridGeometry {
519
+ const box = this.contentBox();
520
+ const { cellWidth, cellHeight } = fonts.metrics;
521
+ const cols = Math.max(
522
+ 1,
523
+ Math.min(Math.floor(box.width / cellWidth), term.cols),
524
+ );
525
+ const rows = Math.max(
526
+ 1,
527
+ Math.min(Math.floor(box.height / cellHeight), term.rows),
528
+ );
529
+ const width = cols * cellWidth;
530
+ const height = rows * cellHeight;
531
+ return {
532
+ x: Math.round(box.x),
533
+ y: Math.round(box.y),
534
+ cols,
535
+ rows,
536
+ width,
537
+ height,
538
+ fits:
539
+ Number.isInteger(box.x) &&
540
+ Number.isInteger(box.y) &&
541
+ width <= box.width &&
542
+ height <= box.height,
543
+ };
544
+ }
545
+
546
+ /**
547
+ * The grid — what the renderer writes every pixel of, every paint.
548
+ *
549
+ * Core's word for it (react-x11#497): a pass inside this rect is painted
550
+ * without the fills that would be under it, and the composite in
551
+ * `present()` replaces those pixels rather than blending over them. Null
552
+ * until the first paint has built a renderer — the mock backend never
553
+ * does, and a promise about pixels nothing will draw is a hole where the
554
+ * background should be — null without fonts or an emulator, when
555
+ * `paintContent` draws nothing, and null when the grid does not fit the
556
+ * box on whole pixels, because the answer is taken literally: an
557
+ * antialiased edge is not opaque.
558
+ */
559
+ override opaqueRect(): Rect | null {
560
+ const term = this._term;
561
+ if (!term || !this._renderer) return null;
562
+ const fonts = this._fontSet();
563
+ if (!fonts) return null;
564
+ const grid = this._gridGeometry(fonts, term);
565
+ if (!grid.fits) return null;
566
+ return { x: grid.x, y: grid.y, width: grid.width, height: grid.height };
461
567
  }
462
568
 
463
- override paint(ctx: Context2D): void {
464
- // background, border, and the clip to this node's rect
465
- super.paint(ctx);
569
+ override paintContent(ctx: Context2D): void {
466
570
  const fonts = this._fontSet();
467
571
  if (!fonts) return;
468
572
  // Before the `term` check on purpose: the component sizes the emulator it
@@ -477,14 +581,8 @@ export class VtTermNode extends Node {
477
581
  const renderer = this._ensureRenderer(cell);
478
582
  if (!renderer) return; // mock backend: no pixel API, so nothing to draw
479
583
 
480
- const box = this.contentBox();
481
- const { cellWidth, cellHeight } = fonts.metrics;
482
- const fitCols = Math.max(1, Math.floor(box.width / cellWidth));
483
- const fitRows = Math.max(1, Math.floor(box.height / cellHeight));
484
- // The emulator resizes a beat later (through the component, off this
485
- // stack), so draw the intersection until it catches up.
486
- const cols = Math.max(1, Math.min(fitCols, term.cols));
487
- const rows = Math.max(1, Math.min(fitRows, term.rows));
584
+ const grid = this._gridGeometry(fonts, term);
585
+ const { cols, rows } = grid;
488
586
  this._cols = cols;
489
587
  this._rows = rows;
490
588
 
@@ -512,8 +610,8 @@ export class VtTermNode extends Node {
512
610
 
513
611
  const result = this._mirror.diff(snapshot);
514
612
  renderer.begin(cell, {
515
- originX: box.x,
516
- originY: box.y,
613
+ originX: grid.x,
614
+ originY: grid.y,
517
615
  cols,
518
616
  rows,
519
617
  metrics: fonts.metrics,
@@ -838,6 +936,7 @@ export class VtTermNode extends Node {
838
936
  if (!term) return;
839
937
  if (term.buffer.active.viewportY !== term.buffer.active.baseY) {
840
938
  term.scrollToBottom();
939
+ this._repaint();
841
940
  }
842
941
  if (this._selection) this.clearSelection();
843
942
  }
@@ -22,6 +22,15 @@
22
22
  // `ctx.drawGlyphs` per colour (one `CompositeGlyphs` covering every run in
23
23
  // the call). That is what keeps "the terminal scrolled" at a handful of
24
24
  // requests rather than a few thousand.
25
+ //
26
+ // The retained renderer's composite is a `copy`, not a blend. Every cell of
27
+ // the grid is filled before it is shown, so replacing the window's pixels
28
+ // with the surface's is the same picture as compositing over them — and
29
+ // saying so is what lets the Cocoa backend send it as a row memcpy instead
30
+ // of building a CGImage and blending it (react-x11#501: 0.42ms against 1.37
31
+ // for a 125x45 grid at 2x), and X11 as `PictOp.Src`. Together with the
32
+ // node's `opaqueRect()` it is why a streaming frame no longer pays for
33
+ // pixels nobody sees.
25
34
  import type { CellMetrics, GlyphRun } from './fonts.js';
26
35
 
27
36
  /** What `ctx.drawGlyphs` takes: a run and where its baseline starts. */
@@ -34,6 +43,10 @@ export interface PositionedRun {
34
43
  /** The slice of ntk's 2d context the renderers draw through. */
35
44
  export interface CellContext {
36
45
  fillStyle: unknown;
46
+ /** Canvas's compositing operator. `'copy'` replaces rather than blends;
47
+ * a backend that cannot draw it refuses the assignment, so read it back
48
+ * rather than assume. Absent on a context with no notion of it. */
49
+ globalCompositeOperation?: string;
37
50
  fillRect(x: number, y: number, w: number, h: number): void;
38
51
  fillRects?(rects: number[]): void;
39
52
  drawGlyphs?(op: number, src: unknown, positioned: PositionedRun[]): void;
@@ -459,11 +472,19 @@ export class RetainedRenderer extends BatchingRenderer {
459
472
  const width = frame.cols * frame.metrics.cellWidth;
460
473
  const height = frame.rows * frame.metrics.cellHeight;
461
474
  if (width <= 0 || height <= 0) return;
462
- // The whole grid, one composite. It is the whole grid rather than the
463
- // frame's dirty bands because `Node.paint` has just filled this node's
464
- // background across everything the damage clip covers, and the damage we
465
- // claimed is the grid rect — the diff decides what is *rendered*, which
466
- // is where the cost is, and the blit is one request either way.
475
+ // The whole grid, one composite, clipped by the pass: the damage the
476
+ // node claimed is the grid rect, the diff decided what was *rendered*
477
+ // into the surface — which is where the cost is — and the composite is
478
+ // one request either way.
479
+ //
480
+ // As a `copy`: the grid is opaque, every cell filled, so replacing the
481
+ // pixels under it is the same picture as blending over them, and it is
482
+ // the shape the Cocoa backend turns into a row memcpy (react-x11#501)
483
+ // and X11 into `PictOp.Src`. Context state, on the window's context, so
484
+ // it goes back afterwards; a backend that cannot draw it refuses the
485
+ // assignment and blends, which is the same pixels.
486
+ const op = ctx.globalCompositeOperation;
487
+ ctx.globalCompositeOperation = 'copy';
467
488
  ctx.drawImage(
468
489
  surface,
469
490
  0,
@@ -475,6 +496,7 @@ export class RetainedRenderer extends BatchingRenderer {
475
496
  width,
476
497
  height,
477
498
  );
499
+ ctx.globalCompositeOperation = op ?? 'source-over';
478
500
  this.count('blits');
479
501
  }
480
502
 
package/src/tree/index.ts CHANGED
@@ -70,6 +70,7 @@ import {
70
70
  later,
71
71
  } from '../internal/timers.js';
72
72
  import type { DelayTick } from '../internal/timers.js';
73
+ import { scaleOf } from '../internal/units.js';
73
74
  import { useReveal } from '../internal/scroll.js';
74
75
  import {
75
76
  BURST_BUDGET,
@@ -1012,12 +1013,14 @@ export function Tree<T = TreeItem>({
1012
1013
  // under it, so their total change is what has to come back out of the
1013
1014
  // scroll offset — otherwise measuring a row you have already scrolled
1014
1015
  // past yanks the list under the pointer.
1015
- const anchor = box ? idx.indexAt(box.scrollY) : 0;
1016
+ const anchor = box ? idx.indexAt(box.scrollY / scaleOf(box)) : 0;
1016
1017
  let shift = 0;
1017
1018
  let changed = false;
1018
1019
  for (const [id, { node, at }] of rowNodes.current) {
1019
1020
  if (rows[at]?.id !== id) continue; // drawn against a list that has moved
1020
- const height = node.abs.height;
1021
+ // `abs` is device; the height index — like every style length — is
1022
+ // logical (../internal/units.ts).
1023
+ const height = node.abs.height / scaleOf(node);
1021
1024
  const was = idx.heightAt(at);
1022
1025
  if (!idx.measure(id, at, height)) continue;
1023
1026
  changed = true;
@@ -1043,7 +1046,7 @@ export function Tree<T = TreeItem>({
1043
1046
  if (!virtualizing) return false;
1044
1047
  const box = scroller.current;
1045
1048
  if (!box) return false;
1046
- const anchor = heights.indexAt(box.scrollY);
1049
+ const anchor = heights.indexAt(box.scrollY / scaleOf(box));
1047
1050
  const before = heights.offsetAt(anchor);
1048
1051
  if (!heights.adapt()) return false;
1049
1052
  reveal.nudge(heights.offsetAt(anchor) - before);
@@ -1075,7 +1078,6 @@ export function Tree<T = TreeItem>({
1075
1078
  let look: DelayTick = null;
1076
1079
  let tries = 0;
1077
1080
  const pass = (): void => {
1078
- (globalThis as any).__ticks = ((globalThis as any).__ticks ?? 0) + 1;
1079
1081
  // `measureRows` first, and its answer handed on: a pass that moved the
1080
1082
  // heights has not settled anything, and an owed scroll judged against
1081
1083
  // the layout it is about to invalidate is not owed any less. During a