react-x11 2.8.3 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,259 @@
1
+ // `useDesktopCalendarEvents()` — the desktop's calendar as rendering state:
2
+ // the occurrences in a range, grouped the way a calendar grid asks for them,
3
+ // re-read when the store moves.
4
+ //
5
+ // The imperative half in `desktopcalendar.js` is complete; what a component
6
+ // wants on top is binding — the tree's connection, a handle that is closed
7
+ // when the view goes away, the grant asked for on the first read rather than
8
+ // at start-up — not another rung.
9
+ //
10
+ // ```jsx
11
+ // const { byDay } = useDesktopCalendarEvents({ from, to, watch: true });
12
+ //
13
+ // <Calendar
14
+ // dayContent={(day) =>
15
+ // byDay.get(day)?.slice(0, 3).map((ev) => (
16
+ // <box key={ev.uid} style={{ width: 4, height: 4, borderRadius: 2,
17
+ // backgroundColor: ev.calendar.color ?? '$accent' }} />
18
+ // ))
19
+ // }
20
+ // />
21
+ // ```
22
+ //
23
+ // `<Calendar>` is `@react-x11/components`; the `'YYYY-MM-DD'` keys are the
24
+ // one contract between the two packages, which is why `byDay` is here and
25
+ // the grid is there.
26
+
27
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
28
+
29
+ import { useAppOrNull } from './appcontext.js';
30
+ import {
31
+ CalendarAccessError,
32
+ byDay as groupByDay,
33
+ desktopCalendar,
34
+ } from './desktopcalendar.js';
35
+ import { openPrivacySettings } from './permissions.js';
36
+
37
+ const NO_EVENTS = [];
38
+ const NO_CALENDARS = [];
39
+ const NO_ERRORS = [];
40
+
41
+ /** The calendars a caller asked for by uid, or every enabled one. `onlyKey`
42
+ * is the comma-joined list, because an array prop is a new identity every
43
+ * render and these effects are keyed on it. */
44
+ function pickCalendars(all, onlyKey) {
45
+ if (!onlyKey) return all.filter((c) => c.enabled);
46
+ const wanted = new Set(onlyKey.split(','));
47
+ return all.filter((c) => wanted.has(c.uid));
48
+ }
49
+
50
+ /**
51
+ * The user's desktop calendar events, as rendering state.
52
+ *
53
+ * **`from` and `to` are read by their timestamps, not their identity**, so
54
+ * `new Date(...)` inline in the render body is fine and will not re-query on
55
+ * every paint. That is the mistake this hook would otherwise invite, and it
56
+ * costs a round trip per frame.
57
+ *
58
+ * `status` is the whole answer:
59
+ *
60
+ * - `'idle'` — `enabled: false`, nothing asked yet.
61
+ * - `'loading'` — a read is in flight. The first one may be showing the
62
+ * system's permission prompt: the first read is what asks, so that a
63
+ * picker the user never opens never prompts.
64
+ * - `'ready'` — `events` is this range.
65
+ * - `'denied'` — the **user's** answer. `openSettings()` puts them in front
66
+ * of the switch; do not show that button on any other status.
67
+ * - `'unavailable'` — the **machine's** answer: no bridge, not a Mac, no
68
+ * Evolution Data Server on the bus. An ordinary state, not a failure —
69
+ * hide the feature rather than reporting it.
70
+ *
71
+ * `errors` is different from all of them: calendars that would not answer
72
+ * while others did, which is one unreachable CalDAV server rather than a
73
+ * failed read.
74
+ */
75
+ export function useDesktopCalendarEvents(options) {
76
+ const { from, to, calendars: only, watch = false, enabled = true } = options;
77
+ const app = useAppOrNull();
78
+
79
+ const [cal, setCal] = useState(null);
80
+ const [backend, setBackend] = useState(null);
81
+ const [events, setEvents] = useState(NO_EVENTS);
82
+ const [found, setFound] = useState(NO_CALENDARS);
83
+ const [errors, setErrors] = useState(NO_ERRORS);
84
+ const [status, setStatus] = useState('idle');
85
+ const [error, setError] = useState(null);
86
+ const [nonce, setNonce] = useState(0);
87
+ const live = useRef(true);
88
+
89
+ const refresh = useCallback(() => setNonce((n) => n + 1), []);
90
+ const openSettings = useCallback(() => openPrivacySettings('calendars'), []);
91
+
92
+ // Timestamps, not the `Date` objects: a caller writing
93
+ // `from={new Date(y, m, 1)}` in the render body hands us a new identity
94
+ // every paint, and an effect keyed on that would re-query every frame.
95
+ const fromMs = from.getTime();
96
+ const toMs = to.getTime();
97
+ const onlyKey = only ? only.join(',') : '';
98
+
99
+ useEffect(() => {
100
+ live.current = true;
101
+ return () => {
102
+ live.current = false;
103
+ };
104
+ }, []);
105
+
106
+ // The handle, opened once per connection: the `osascript` child and the
107
+ // bus reference behind it are shared, and one hook must not open a second
108
+ // of either every time the month changes.
109
+ useEffect(() => {
110
+ if (!enabled) {
111
+ setCal(null);
112
+ setBackend(null);
113
+ setStatus('idle');
114
+ return undefined;
115
+ }
116
+
117
+ let alive = true;
118
+ let handle = null;
119
+ setStatus('loading');
120
+ desktopCalendar({ app }).then(
121
+ (opened) => {
122
+ if (!alive) {
123
+ void opened?.close();
124
+ return;
125
+ }
126
+ handle = opened;
127
+ setCal(opened);
128
+ setBackend(opened ? opened.backend : null);
129
+ if (!opened) {
130
+ setEvents(NO_EVENTS);
131
+ setStatus('unavailable');
132
+ }
133
+ },
134
+ (err) => {
135
+ if (!alive) return;
136
+ setError(err instanceof Error ? err : new Error(String(err)));
137
+ setStatus('unavailable');
138
+ },
139
+ );
140
+
141
+ return () => {
142
+ alive = false;
143
+ setCal(null);
144
+ void handle?.close();
145
+ };
146
+ }, [app, enabled]);
147
+
148
+ // The query.
149
+ useEffect(() => {
150
+ if (!cal) return undefined;
151
+
152
+ let alive = true;
153
+ void (async () => {
154
+ setStatus('loading');
155
+ setError(null);
156
+ try {
157
+ const all = await cal.listCalendars();
158
+ if (!alive) return;
159
+ setFound(all);
160
+
161
+ const result = await cal.eventsBetween(
162
+ new Date(fromMs),
163
+ new Date(toMs),
164
+ {
165
+ calendars: pickCalendars(all, onlyKey),
166
+ },
167
+ );
168
+ if (!alive) return;
169
+ setEvents(result.events);
170
+ setErrors(result.errors);
171
+ setStatus('ready');
172
+ } catch (err) {
173
+ if (!alive) return;
174
+ setError(err instanceof Error ? err : new Error(String(err)));
175
+ setEvents(NO_EVENTS);
176
+ // The user's refusal and the machine's silence are different words:
177
+ // one has a Settings switch behind it and the other does not. A
178
+ // request that came back still undecided is the machine's — TCC
179
+ // would not even ask — so it is 'unavailable', not a refusal the
180
+ // user could take back.
181
+ const refused =
182
+ err instanceof CalendarAccessError && err.status !== 'prompt';
183
+ setStatus(refused ? 'denied' : 'unavailable');
184
+ }
185
+ })();
186
+
187
+ return () => {
188
+ alive = false;
189
+ };
190
+ // `refresh` is stable; `nonce` is what a manual refresh moves.
191
+ }, [cal, fromMs, toMs, onlyKey, nonce]);
192
+
193
+ // The subscription is a **separate** effect, and deliberately not keyed on
194
+ // `nonce`: a change has to re-run the query, and only the query. Watching
195
+ // from inside the effect the change re-runs means every notification tears
196
+ // the views down and starts new ones — which is a loop as soon as anything
197
+ // is delivered while a view is starting, and was one for as long as EDS's
198
+ // `Start()` reported the range's existing contents as a change.
199
+ useEffect(() => {
200
+ if (!cal || !watch) return undefined;
201
+
202
+ let alive = true;
203
+ let stopWatching = null;
204
+ void (async () => {
205
+ try {
206
+ const all = await cal.listCalendars();
207
+ if (!alive) return;
208
+ stopWatching = await cal.watch(
209
+ new Date(fromMs),
210
+ new Date(toMs),
211
+ () => {
212
+ // Re-query rather than patch: a recurrence master edited months
213
+ // away changes what this range looks like.
214
+ if (alive && live.current) refresh();
215
+ },
216
+ { calendars: pickCalendars(all, onlyKey) },
217
+ );
218
+ if (!alive) await stopWatching();
219
+ } catch {
220
+ // No grant, no service, nothing to watch. The query effect above is
221
+ // what reports that to the caller; a second copy would only race.
222
+ }
223
+ })();
224
+
225
+ return () => {
226
+ alive = false;
227
+ void (async () => {
228
+ if (stopWatching) await stopWatching();
229
+ })();
230
+ };
231
+ }, [cal, watch, fromMs, toMs, onlyKey, refresh]);
232
+
233
+ const grouped = useMemo(() => groupByDay(events), [events]);
234
+
235
+ return useMemo(
236
+ () => ({
237
+ events,
238
+ byDay: grouped,
239
+ calendars: found,
240
+ errors,
241
+ status,
242
+ backend,
243
+ error,
244
+ refresh,
245
+ openSettings,
246
+ }),
247
+ [
248
+ events,
249
+ grouped,
250
+ found,
251
+ errors,
252
+ status,
253
+ backend,
254
+ error,
255
+ refresh,
256
+ openSettings,
257
+ ],
258
+ );
259
+ }
package/src/glnodes.js CHANGED
@@ -11,6 +11,7 @@ import { cssColorStraight } from 'ntk';
11
11
  export { directGLFailure, hasDirectGL } from './glbackend.js';
12
12
 
13
13
  import { Node } from './nodes.js';
14
+ import { FramePacer, resolveFrameRate } from './pacing.js';
14
15
 
15
16
  // One visual query per (app, spec): GetFBConfigs is a round trip and every
16
17
  // <glarea> in an app wants the same answer.
@@ -99,6 +100,9 @@ const px = (v) => Math.max(1, Math.round(v || 0));
99
100
  * - `clearColor` — CSS colour or `[r, g, b, a]` floats (default black).
100
101
  * - `frameLoop` — `'demand'` (default: redraw on prop/size/expose changes)
101
102
  * or `'always'` (drive ntk's frame clock continuously).
103
+ * - `frameRate` — how the frames are paced when they are expensive
104
+ * (src/pacing.js): a preset, a cap, or the three numbers, the same
105
+ * vocabulary as `<window frameRate>`. Defaults to the owning window's.
102
106
  * - `glx` — a `chooseGLXConfig` spec, e.g. `{ DEPTH_SIZE: 24 }`.
103
107
  *
104
108
  * The X child window is stacked above everything drawn in the parent, so 2D
@@ -114,12 +118,36 @@ export class GlAreaNode extends Node {
114
118
  this._frameScheduled = false;
115
119
  this._created = false;
116
120
  this._pointerDirty = true;
121
+ // The frame pacer (src/pacing.js), the surface's own: a scene's frames
122
+ // are drawn on a clock of their own, and what one costs — `onDraw` and
123
+ // the swap, on this thread — is what decides whether the next may
124
+ // start at once. The policy is this element's `frameRate`, else the
125
+ // owning window's, read at each request so a change on either follows.
126
+ this._pacer = new FramePacer();
127
+ this._ownPolicy = null;
128
+ this._syncFramePolicy();
117
129
  }
118
130
 
119
131
  get isGlArea() {
120
132
  return true;
121
133
  }
122
134
 
135
+ /** The policy this surface paces by: its own prop, else the window's. */
136
+ _framePolicy() {
137
+ if (this.props.frameRate !== undefined && this.props.frameRate !== null) {
138
+ return this._ownPolicy;
139
+ }
140
+ return this.root?._framePolicy ?? this._pacer.policy;
141
+ }
142
+
143
+ _syncFramePolicy() {
144
+ const value = this.props.frameRate;
145
+ this._ownPolicy =
146
+ value === undefined || value === null
147
+ ? null
148
+ : resolveFrameRate(value, '<glarea frameRate>');
149
+ }
150
+
123
151
  _setRoot(root) {
124
152
  super._setRoot(root);
125
153
  // the owning window may already exist (a <glarea> mounted into a live
@@ -275,8 +303,21 @@ export class GlAreaNode extends Node {
275
303
  this.requestFrame();
276
304
  }
277
305
 
278
- /** Draw one frame on the child window's next frame tick. */
306
+ /**
307
+ * Draw one frame on the child window's next frame tick — after whatever
308
+ * wait the pacer asks for (src/pacing.js). Off by default it answers
309
+ * "now"; under an adaptive policy a scene whose frames cost more than
310
+ * their share of the thread is held between them, and a `frameLoop` of
311
+ * `'always'` becomes a loop at the budget rather than at the display.
312
+ */
279
313
  requestFrame() {
314
+ if (!this.window || this.destroyed || this._frameScheduled) return;
315
+ this._pacer.configure(this._framePolicy());
316
+ if (this._pacer.defer(() => this._requestFrameNow())) return;
317
+ this._requestFrameNow();
318
+ }
319
+
320
+ _requestFrameNow() {
280
321
  if (!this.window || this.destroyed || this._frameScheduled) return;
281
322
  this._frameScheduled = true;
282
323
  const schedule =
@@ -290,13 +331,28 @@ export class GlAreaNode extends Node {
290
331
  }
291
332
 
292
333
  _drawFrame() {
334
+ const pacer = this._pacer;
335
+ pacer.began();
336
+ let drawn = false;
337
+ try {
338
+ drawn = this._drawFrameNow();
339
+ } finally {
340
+ pacer.ended(undefined, drawn);
341
+ }
342
+ // after the frame is priced, so the loop's next frame is judged by
343
+ // this one rather than by the one before
344
+ if (drawn && this.props.frameLoop === 'always') this.requestFrame();
345
+ }
346
+
347
+ /** The frame itself; true when it drew. */
348
+ _drawFrameNow() {
293
349
  const gl = this.gl;
294
- if (!gl || this.destroyed) return;
350
+ if (!gl || this.destroyed) return false;
295
351
  const direct = gl.backend === 'direct';
296
352
  // On the direct backend every buffer may still be held by the display,
297
353
  // and drawing into one before it comes back would paint what is on
298
354
  // screen. `onFrameAvailable` asks for this frame again when one frees.
299
- if (direct && gl.canRender && !gl.canRender()) return;
355
+ if (direct && gl.canRender && !gl.canRender()) return false;
300
356
  // binds this surface — the GPU context is shared between every <glarea>
301
357
  // on the connection — and picks up a resize
302
358
  gl.makeCurrent?.();
@@ -325,7 +381,7 @@ export class GlAreaNode extends Node {
325
381
  }
326
382
  this.props.onDraw?.(gl, info);
327
383
  gl.SwapBuffers();
328
- if (this.props.frameLoop === 'always') this.requestFrame();
384
+ return true;
329
385
  }
330
386
 
331
387
  /**
@@ -357,7 +413,9 @@ export class GlAreaNode extends Node {
357
413
  }
358
414
 
359
415
  applyProps(newProps, oldProps) {
416
+ const before = oldProps ?? this.props;
360
417
  super.applyProps(newProps, oldProps);
418
+ if (newProps.frameRate !== before.frameRate) this._syncFramePolicy();
361
419
  // onDraw/clearColor are read at frame time, so any update is a new frame
362
420
  this.requestFrame();
363
421
  }
@@ -375,6 +433,7 @@ export class GlAreaNode extends Node {
375
433
  destroySubtree() {
376
434
  if (this.destroyed) return;
377
435
  super.destroySubtree();
436
+ this._pacer.cancel();
378
437
  this.gl?.destroy?.();
379
438
  this.gl = null;
380
439
  this.window?.destroy?.();
package/src/index.d.ts CHANGED
@@ -9,7 +9,7 @@
9
9
 
10
10
  import type { ReactNode, RefObject } from 'react';
11
11
  import type { DrawnNode, NtkApp, NtkWindow } from './types/nodes.js';
12
- import type { ReactX11Elements } from './types/elements.js';
12
+ import type { FrameRate, ReactX11Elements } from './types/elements.js';
13
13
 
14
14
  export * from './types/style.js';
15
15
  export * from './types/events.js';
@@ -29,6 +29,7 @@ export * from './types/launcher.js';
29
29
  export * from './types/tray.js';
30
30
  export * from './types/permissions.js';
31
31
  export * from './types/notifications.js';
32
+ export * from './types/desktopcalendar.js';
32
33
 
33
34
  /**
34
35
  * The XID of the X11 window a ref points at, or `null` if there is not one
@@ -232,6 +233,15 @@ export interface RootOptions {
232
233
  * run each. 6 by default; 0 paints glyphs at every size.
233
234
  */
234
235
  textStripBelow?: number;
236
+ /**
237
+ * How this root's windows pace their frames when their content changes
238
+ * faster than the display refreshes — the default for every `<window>`
239
+ * and `<glarea>` that names no `frameRate` of its own. `'display'` (every
240
+ * frame the clock gives) unless said otherwise; `'adaptive'` holds paints
241
+ * to a quarter of the time under a flood. `REACT_X11_FRAME_RATE` overrides
242
+ * it, and the props, from the environment. See {@link FrameRate}.
243
+ */
244
+ frameRate?: FrameRate;
235
245
  /** `':1'`, `'host:0.0'`, or a unix socket path. Defaults to `$DISPLAY`. */
236
246
  display?: string;
237
247
  /**
package/src/index.js CHANGED
@@ -27,6 +27,15 @@ export {
27
27
  notify,
28
28
  } from './notifications.js';
29
29
  export { useNotifier } from './notificationhooks.js';
30
+ export {
31
+ CalendarAccessError,
32
+ NoCalendarServiceError,
33
+ byDay,
34
+ calendarBackend,
35
+ dayKey,
36
+ desktopCalendar,
37
+ } from './desktopcalendar.js';
38
+ export { useDesktopCalendarEvents } from './desktopcalendarhooks.js';
30
39
  export { parseUriList } from './transfer.js';
31
40
  export { useApp, useClipboard, useSupports } from './appcontext.js';
32
41
  export { BusUnavailableError, closeBus, sessionBus, systemBus } from './bus.js';
package/src/node.d.ts CHANGED
@@ -25,8 +25,178 @@ import type {
25
25
  WheelEvent,
26
26
  } from './types/events.js';
27
27
 
28
- /** ntk's 2d context. Typed loosely — it is ntk's API, not ours. */
29
- export type Context2D = unknown;
28
+ /** A gradient, as `createLinearGradient` answers with. */
29
+ export interface CanvasGradientLike {
30
+ addColorStop(offset: number, color: string): void;
31
+ }
32
+
33
+ /** An RGBA pixel block, as `createImageData` answers with. */
34
+ export interface ImageDataLike {
35
+ readonly data: Uint8ClampedArray;
36
+ readonly width: number;
37
+ readonly height: number;
38
+ }
39
+
40
+ /**
41
+ * The 2d context a node paints into — the canvas-shaped subset **both**
42
+ * backends implement: ntk's `RenderingContext2D` over XRender on X11, and
43
+ * `CocoaContext2D` over CoreGraphics on macOS. Declared as the contract an
44
+ * element may rely on rather than as either class: what is here is on
45
+ * both, and a member one backend has and the other does not is optional
46
+ * here or absent. Coordinates are device pixels in the owning window's
47
+ * space — `abs`, `contentBox()` — unless a transform says otherwise.
48
+ * Anything ntk documents beyond this is reachable at runtime and is
49
+ * ntk's to change.
50
+ */
51
+ export interface Context2D {
52
+ fillStyle: string | CanvasGradientLike;
53
+ strokeStyle: string | CanvasGradientLike;
54
+ lineWidth: number;
55
+ lineCap: 'butt' | 'round' | 'square';
56
+ lineJoin: 'miter' | 'round' | 'bevel';
57
+ globalAlpha: number;
58
+ /** A CSS font shorthand, `'13px sans-serif'`. */
59
+ font: string;
60
+ shadowBlur: number;
61
+ shadowOffsetX: number;
62
+ shadowOffsetY: number;
63
+ shadowColor: string;
64
+ /**
65
+ * X11 only: XRender's compositing operator — `'source-over'` (the
66
+ * default), `'copy'`, and the rest ntk documents. Absent on the cocoa
67
+ * backend, whose bridge has no blend-mode verb yet; test for it with
68
+ * `'globalCompositeOperation' in ctx`.
69
+ */
70
+ globalCompositeOperation?: string;
71
+ save(): void;
72
+ restore(): void;
73
+ translate(x: number, y: number): void;
74
+ scale(x: number, y: number): void;
75
+ rotate(angle: number): void;
76
+ transform(
77
+ a: number,
78
+ b: number,
79
+ c: number,
80
+ d: number,
81
+ e: number,
82
+ f: number,
83
+ ): void;
84
+ setTransform(
85
+ a: number,
86
+ b: number,
87
+ c: number,
88
+ d: number,
89
+ e: number,
90
+ f: number,
91
+ ): void;
92
+ resetTransform(): void;
93
+ getTransform(): {
94
+ a: number;
95
+ b: number;
96
+ c: number;
97
+ d: number;
98
+ e: number;
99
+ f: number;
100
+ };
101
+ setLineDash(segments: number[]): void;
102
+ getLineDash(): number[];
103
+ beginPath(): void;
104
+ closePath(): void;
105
+ moveTo(x: number, y: number): void;
106
+ lineTo(x: number, y: number): void;
107
+ bezierCurveTo(
108
+ cp1x: number,
109
+ cp1y: number,
110
+ cp2x: number,
111
+ cp2y: number,
112
+ x: number,
113
+ y: number,
114
+ ): void;
115
+ quadraticCurveTo(cpx: number, cpy: number, x: number, y: number): void;
116
+ arc(
117
+ x: number,
118
+ y: number,
119
+ radius: number,
120
+ startAngle: number,
121
+ endAngle: number,
122
+ anticlockwise?: boolean,
123
+ ): void;
124
+ rect(x: number, y: number, width: number, height: number): void;
125
+ roundRect(
126
+ x: number,
127
+ y: number,
128
+ width: number,
129
+ height: number,
130
+ radii: number | number[],
131
+ ): void;
132
+ fill(fillRule?: 'nonzero' | 'evenodd'): void;
133
+ stroke(): void;
134
+ clip(fillRule?: 'nonzero' | 'evenodd'): void;
135
+ fillRect(x: number, y: number, width: number, height: number): void;
136
+ /** Many rectangles in one operation — one request on X11, one
137
+ * CoreGraphics call on macOS. `[x, y, w, h]` quadruples, flat or nested. */
138
+ fillRects(rects: number[] | number[][]): void;
139
+ strokeRect(x: number, y: number, width: number, height: number): void;
140
+ clearRect(x: number, y: number, width: number, height: number): void;
141
+ fillText(text: string, x: number, y: number): void;
142
+ measureText(text: string): { width: number; [key: string]: unknown };
143
+ /**
144
+ * Composite an offscreen `Surface` (`react-x11/ntk`) — whole at a point,
145
+ * whole into a rect, or a source rect of it into a destination rect: the
146
+ * canvas overloads. One composite either way.
147
+ */
148
+ drawImage(image: unknown, dx: number, dy: number): void;
149
+ drawImage(
150
+ image: unknown,
151
+ dx: number,
152
+ dy: number,
153
+ dw: number,
154
+ dh: number,
155
+ ): void;
156
+ drawImage(
157
+ image: unknown,
158
+ sx: number,
159
+ sy: number,
160
+ sw: number,
161
+ sh: number,
162
+ dx: number,
163
+ dy: number,
164
+ dw: number,
165
+ dh: number,
166
+ ): void;
167
+ createLinearGradient(
168
+ x0: number,
169
+ y0: number,
170
+ x1: number,
171
+ y1: number,
172
+ ): CanvasGradientLike;
173
+ createImageData(width: number, height: number): ImageDataLike;
174
+ /** Raw pixels, straight RGBA, written transform- and clip-free — the
175
+ * canvas contract (docs/elements.md "<canvas>"). */
176
+ putImageData(data: ImageDataLike, x: number, y: number): void;
177
+ }
178
+
179
+ /**
180
+ * What `paintCachePlan` answers with: draw this node's content once into a
181
+ * surface under `key`, and composite that until the key changes
182
+ * (docs/extending.md "Drawing once instead of every frame").
183
+ */
184
+ export interface PaintCachePlan {
185
+ /** Identity: the same key must mean the same pixels. Name every input
186
+ * `paintCached` reads. */
187
+ key: string;
188
+ /** Where the surface goes, in device pixels — window coordinates. */
189
+ x: number;
190
+ y: number;
191
+ /** Its size, in device pixels. */
192
+ width: number;
193
+ height: number;
194
+ /** `'argb32'` (the default), or `'a8'` for coverage tinted at composite
195
+ * time — one rendered copy per key serves every ink. */
196
+ format?: 'argb32' | 'a8';
197
+ /** The colour an `'a8'` surface is painted through. */
198
+ tint?: string;
199
+ }
30
200
 
31
201
  /** How an axis is bounded when layout asks an element for its size.
32
202
  * `'exactly'` — the style decided this axis; `'at-most'` — that many pixels
@@ -284,6 +454,48 @@ export declare class Node {
284
454
  /** Draw. A subclass calls `super.paint(ctx)` first, for the background,
285
455
  * border and clip, then draws inside `this.abs`. */
286
456
  paint(ctx: Context2D): void;
457
+ /**
458
+ * Draw between the background and the children — where every built-in
459
+ * draws, and the seam to override rather than `paint`: `paint` draws the
460
+ * background, then this, then the children, the border and the focus
461
+ * ring, and a scroller's bars, and an element that overrides `paint`
462
+ * has to keep all of that in the right order itself. Draws inside
463
+ * `contentBox()`; the clip is already set. The default draws nothing.
464
+ */
465
+ paintContent(ctx: Context2D): void;
466
+ /**
467
+ * The paint-cache protocol, with `paintCached`: implement both or
468
+ * neither. Answer a plan to have this frame's content drawn once into a
469
+ * surface under `plan.key` and composited from it until the key changes,
470
+ * or `null` to opt out this frame — the right answer whenever the paint
471
+ * depends on something the key cannot see (a caret, a hover, anything
472
+ * animating). The key is the whole correctness surface.
473
+ */
474
+ paintCachePlan?(ctx: Context2D): PaintCachePlan | null;
475
+ /**
476
+ * Draw the content the plan named, at the **origin of `box`** — surface
477
+ * coordinates, not `this.abs`. `ink` is the colour a mono drawing (one
478
+ * that asked for `'a8'`) must paint in: white where the surface is
479
+ * coverage and the tint arrives at composite time, the tint itself on a
480
+ * backend without coverage surfaces. A multi-colour drawing ignores it.
481
+ * `paintDamage()` is null in here: a cached copy is drawn whole.
482
+ */
483
+ paintCached?(ctx: Context2D, box: Rect, ink?: string): void;
484
+ /**
485
+ * The rect this element writes opaque pixels over on every paint — in
486
+ * window coordinates like `abs`, whole pixels — or `null`, the default,
487
+ * which promises nothing.
488
+ *
489
+ * A pass that lies inside it is painted without the fills that would be
490
+ * under it: the window's background, this node's own and every
491
+ * ancestor's. That is what an element with a retained surface it draws
492
+ * whole — a terminal, a media frame, a chart — answers with, and it then
493
+ * claims its damage as a **rect inside the answer** rather than as the
494
+ * node, which is inflated by a pixel of slop and so never covered. The
495
+ * promise is the element's to keep: every pixel, alpha one, on every
496
+ * paint, whatever the props. A translucent element answers `null`.
497
+ */
498
+ opaqueRect(): Rect | null;
287
499
  /**
288
500
  * The rect this paint pass is repainting, or null when it is repainting
289
501
  * the whole window — and null outside a paint, which means the same