@react-x11/components 0.7.0 → 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 (46) 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/html/dom.d.ts +8 -1
  6. package/dist/html/dom.d.ts.map +1 -1
  7. package/dist/html/dom.js +17 -2
  8. package/dist/html/dom.js.map +1 -1
  9. package/dist/html/index.d.ts +4 -0
  10. package/dist/html/index.d.ts.map +1 -1
  11. package/dist/html/index.js.map +1 -1
  12. package/dist/internal/scroll.d.ts.map +1 -1
  13. package/dist/internal/scroll.js +32 -15
  14. package/dist/internal/scroll.js.map +1 -1
  15. package/dist/internal/units.d.ts +2 -0
  16. package/dist/internal/units.d.ts.map +1 -0
  17. package/dist/internal/units.js +35 -0
  18. package/dist/internal/units.js.map +1 -0
  19. package/dist/internal/window.d.ts.map +1 -1
  20. package/dist/internal/window.js +4 -2
  21. package/dist/internal/window.js.map +1 -1
  22. package/dist/table/index.d.ts.map +1 -1
  23. package/dist/table/index.js +14 -5
  24. package/dist/table/index.js.map +1 -1
  25. package/dist/terminal/vt/node.d.ts +56 -3
  26. package/dist/terminal/vt/node.d.ts.map +1 -1
  27. package/dist/terminal/vt/node.js +106 -26
  28. package/dist/terminal/vt/node.js.map +1 -1
  29. package/dist/terminal/vt/renderer.d.ts +4 -0
  30. package/dist/terminal/vt/renderer.d.ts.map +1 -1
  31. package/dist/terminal/vt/renderer.js +14 -5
  32. package/dist/terminal/vt/renderer.js.map +1 -1
  33. package/dist/tree/index.d.ts.map +1 -1
  34. package/dist/tree/index.js +6 -4
  35. package/dist/tree/index.js.map +1 -1
  36. package/package.json +3 -3
  37. package/src/color-picker/ColorPicker.ts +107 -81
  38. package/src/html/dom.ts +17 -2
  39. package/src/html/index.ts +4 -0
  40. package/src/internal/scroll.ts +34 -15
  41. package/src/internal/units.ts +34 -0
  42. package/src/internal/window.ts +4 -2
  43. package/src/table/index.ts +14 -5
  44. package/src/terminal/vt/node.ts +128 -29
  45. package/src/terminal/vt/renderer.ts +27 -5
  46. package/src/tree/index.ts +6 -4
@@ -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