@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.
- package/README.md +214 -31
- package/dist/color-picker/ColorPicker.d.ts.map +1 -1
- package/dist/color-picker/ColorPicker.js +43 -33
- package/dist/color-picker/ColorPicker.js.map +1 -1
- package/dist/internal/scroll.d.ts.map +1 -1
- package/dist/internal/scroll.js +32 -15
- package/dist/internal/scroll.js.map +1 -1
- package/dist/internal/units.d.ts +2 -0
- package/dist/internal/units.d.ts.map +1 -0
- package/dist/internal/units.js +35 -0
- package/dist/internal/units.js.map +1 -0
- package/dist/internal/window.d.ts.map +1 -1
- package/dist/internal/window.js +4 -2
- package/dist/internal/window.js.map +1 -1
- package/dist/table/index.d.ts.map +1 -1
- package/dist/table/index.js +14 -5
- package/dist/table/index.js.map +1 -1
- package/dist/terminal/vt/node.d.ts +56 -3
- package/dist/terminal/vt/node.d.ts.map +1 -1
- package/dist/terminal/vt/node.js +106 -26
- package/dist/terminal/vt/node.js.map +1 -1
- package/dist/terminal/vt/renderer.d.ts +4 -0
- package/dist/terminal/vt/renderer.d.ts.map +1 -1
- package/dist/terminal/vt/renderer.js +14 -5
- package/dist/terminal/vt/renderer.js.map +1 -1
- package/dist/tree/index.d.ts.map +1 -1
- package/dist/tree/index.js +6 -4
- package/dist/tree/index.js.map +1 -1
- package/package.json +3 -3
- package/src/color-picker/ColorPicker.ts +107 -81
- package/src/internal/scroll.ts +34 -15
- package/src/internal/units.ts +34 -0
- package/src/internal/window.ts +4 -2
- package/src/table/index.ts +14 -5
- package/src/terminal/vt/node.ts +128 -29
- package/src/terminal/vt/renderer.ts +27 -5
- package/src/tree/index.ts +6 -4
package/src/internal/scroll.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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:
|
|
228
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/internal/window.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/table/index.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
1001
|
-
|
|
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);
|
package/src/terminal/vt/node.ts
CHANGED
|
@@ -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.
|
|
199
|
-
//
|
|
200
|
-
|
|
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.
|
|
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
|
|
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(
|
|
240
|
-
term.onScroll(dirty),
|
|
241
|
-
term.onCursorMove(dirty),
|
|
265
|
+
term.onWriteParsed(() => this._repaint()),
|
|
242
266
|
term.onResize(() => {
|
|
243
267
|
this._mirror.invalidate();
|
|
244
|
-
|
|
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
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
|
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
|
|
481
|
-
const {
|
|
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:
|
|
516
|
-
originY:
|
|
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
|
|
463
|
-
//
|
|
464
|
-
//
|
|
465
|
-
//
|
|
466
|
-
//
|
|
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
|
-
|
|
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
|