react-x11 0.0.1 → 1.2.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/src/nodes.js ADDED
@@ -0,0 +1,1985 @@
1
+ // Retained node tree: one lightweight JS node per host element, one yoga node
2
+ // per drawn element, painted into the owning <window>'s single 2d context on
3
+ // ntk's frame clock. Only <window> owns a real X11 window (see NEXT_STEPS.md
4
+ // §4 for the rationale).
5
+ import {
6
+ Yoga,
7
+ applyLayoutStyle,
8
+ paintPropsChanged,
9
+ textStyleFrom,
10
+ DEFAULT_TEXT_STYLE,
11
+ TEXT_LAYOUT_PROPS,
12
+ } from './styles.js';
13
+ import { EventManager } from './events.js';
14
+ import { runWithPriority, DiscreteEventPriority } from './priority.js';
15
+
16
+ const DRAWN_KINDS = new Set([
17
+ 'box',
18
+ 'text',
19
+ 'image',
20
+ 'canvas',
21
+ 'scrollview',
22
+ 'textinput',
23
+ 'textarea',
24
+ 'markdown',
25
+ 'html',
26
+ 'svg',
27
+ 'tex',
28
+ ]);
29
+
30
+ // X ConfigureWindow stack-mode: Below places the window directly under the
31
+ // named sibling (X11 protocol, ConfigureWindow).
32
+ const STACK_BELOW = 1;
33
+
34
+ // Windows whose child stacking order may have gone stale during the commit
35
+ // in progress; drained by flushWindowRestacks from resetAfterCommit.
36
+ const pendingRestack = new Set();
37
+
38
+ /** Apply any child-window stacking changes the commit produced, once. */
39
+ export function flushWindowRestacks() {
40
+ const nodes = [...pendingRestack];
41
+ pendingRestack.clear();
42
+ for (const node of nodes) node._restackWindowChildren();
43
+ }
44
+
45
+ // DevTools' measureHostInstance dereferences instance.ownerDocument
46
+ // unconditionally once getClientRects exists; a null documentElement and
47
+ // defaultView give it zero scroll offsets and no crash.
48
+ export const DEVTOOLS_FAKE_DOCUMENT = {
49
+ documentElement: null,
50
+ defaultView: null,
51
+ };
52
+
53
+ /** CSS's `transparent` keyword means "paint nothing". ntk's colour parser
54
+ * does not know it and throws deep inside the 2d context, taking the whole
55
+ * frame with it, so filter it out at the source alongside null/''. */
56
+ function isPaintedColor(color) {
57
+ return Boolean(color) && color !== 'transparent';
58
+ }
59
+
60
+ /** Equality for props that may be a scalar, an array or a plain object
61
+ * (window hints are all three shapes), so an unchanged inline object
62
+ * literal does not re-send the property every render. */
63
+ function shallowEqual(a, b) {
64
+ if (a === b) return true;
65
+ if (typeof a !== 'object' || typeof b !== 'object' || !a || !b) return false;
66
+ if (Array.isArray(a) !== Array.isArray(b)) return false;
67
+ const ka = Object.keys(a);
68
+ const kb = Object.keys(b);
69
+ return ka.length === kb.length && ka.every((k) => a[k] === b[k]);
70
+ }
71
+
72
+ export class Node {
73
+ get ownerDocument() {
74
+ return DEVTOOLS_FAKE_DOCUMENT;
75
+ }
76
+
77
+ constructor(kind, props, app, { yoga = true } = {}) {
78
+ this.kind = kind;
79
+ this.props = props;
80
+ this.app = app;
81
+ this.parent = null;
82
+ this.children = [];
83
+ this.root = null; // owning WindowNode once attached
84
+ this.hidden = false;
85
+ this.destroyed = false;
86
+ // absolute rect within the owning window, filled by absolutize()
87
+ this.abs = { x: 0, y: 0, width: 0, height: 0 };
88
+ this.yoga = yoga ? Yoga.Node.create() : null;
89
+ if (this.yoga) {
90
+ applyLayoutStyle(this.yoga, props);
91
+ }
92
+ }
93
+
94
+ get isWindow() {
95
+ return this.kind === 'window';
96
+ }
97
+
98
+ /** Number of yoga-bearing children before `index` (window children and
99
+ * text spans/chunks do not join the parent's yoga tree). */
100
+ _yogaIndexAt(index) {
101
+ let n = 0;
102
+ for (let i = 0; i < index; i++) {
103
+ if (this._joinsYoga(this.children[i])) n++;
104
+ }
105
+ return n;
106
+ }
107
+
108
+ _joinsYoga(child) {
109
+ return Boolean(this.yoga && child.yoga && !child.isWindow);
110
+ }
111
+
112
+ appendChild(child) {
113
+ this.insertBefore(child, null);
114
+ }
115
+
116
+ /** Splice `child` in front of `beforeChild` (end of the list when that is
117
+ * null), first taking it out of its old slot: React reorders a keyed list
118
+ * by calling insertBefore with a child that is *already* mounted here, and
119
+ * without the removal it would appear twice. Returns the new index. */
120
+ _spliceChild(child, beforeChild) {
121
+ const from = this.children.indexOf(child);
122
+ if (from !== -1) this.children.splice(from, 1);
123
+ const before =
124
+ beforeChild == null ? -1 : this.children.indexOf(beforeChild);
125
+ const index = before === -1 ? this.children.length : before;
126
+ this.children.splice(index, 0, child);
127
+ return index;
128
+ }
129
+
130
+ insertBefore(child, beforeChild) {
131
+ if (child.isPopup) {
132
+ // popups live anywhere in the JSX tree but are independent
133
+ // override-redirect windows: bookkeeping only, no yoga, no paint
134
+ this._spliceChild(child, beforeChild);
135
+ child.parent = this;
136
+ return;
137
+ }
138
+ if (child.isWindow) {
139
+ throw new Error(
140
+ `react-x11: <window> cannot be nested inside <${this.kind}>; ` +
141
+ 'windows may only appear at the root or inside another <window>.',
142
+ );
143
+ }
144
+ // a move has to leave the yoga tree too — yoga aborts on insertChild of
145
+ // a node that still has a parent
146
+ if (this.children.includes(child) && this._joinsYoga(child)) {
147
+ this.yoga.removeChild(child.yoga);
148
+ }
149
+ const index = this._spliceChild(child, beforeChild);
150
+ child.parent = this;
151
+ if (this._joinsYoga(child)) {
152
+ this.yoga.insertChild(child.yoga, this._yogaIndexAt(index));
153
+ }
154
+ child._setRoot(this.root);
155
+ this._textContentChanged();
156
+ this.root?.invalidate(true);
157
+ }
158
+
159
+ removeChild(child) {
160
+ const index = this.children.indexOf(child);
161
+ if (index === -1) return;
162
+ this.children.splice(index, 1);
163
+ if (this._joinsYoga(child)) {
164
+ this.yoga.removeChild(child.yoga);
165
+ }
166
+ child.parent = null;
167
+ child.destroySubtree();
168
+ if (child.yoga && !child.isWindow) {
169
+ child.yoga.freeRecursive();
170
+ child.yoga = null;
171
+ }
172
+ this._textContentChanged();
173
+ this.root?.invalidate(true);
174
+ }
175
+
176
+ /** Destroy real resources (X windows) in this subtree. Yoga nodes are
177
+ * freed by the caller via freeRecursive on the subtree top. */
178
+ destroySubtree() {
179
+ this.destroyed = true;
180
+ for (const child of this.children) child.destroySubtree();
181
+ }
182
+
183
+ _setRoot(root) {
184
+ if (this.root === root) return;
185
+ this.root = root;
186
+ for (const child of this.children) {
187
+ if (!child.isWindow) child._setRoot(root);
188
+ }
189
+ }
190
+
191
+ /** Called when descendant text content may have changed; overridden by
192
+ * TextNode, forwarded upward by spans/chunks. */
193
+ _textContentChanged() {}
194
+
195
+ applyProps(newProps, oldProps) {
196
+ const prev = this.props;
197
+ this.props = newProps;
198
+ let layoutChanged = false;
199
+ if (this.yoga) {
200
+ layoutChanged = applyLayoutStyle(this.yoga, newProps, oldProps ?? prev);
201
+ }
202
+ if (Boolean(newProps.trapFocus) !== Boolean((oldProps ?? prev).trapFocus)) {
203
+ this._syncFocusScope();
204
+ }
205
+ this.root?.invalidate(layoutChanged);
206
+ }
207
+
208
+ setHidden(hidden) {
209
+ this.hidden = hidden;
210
+ if (this.yoga) {
211
+ this.yoga.setDisplay(
212
+ hidden || this.props.display === 'none'
213
+ ? Yoga.DISPLAY_NONE
214
+ : Yoga.DISPLAY_FLEX,
215
+ );
216
+ }
217
+ this.root?.invalidate(true);
218
+ }
219
+
220
+ /**
221
+ * Focus this node, as clicking it would: the owning window's focus moves
222
+ * here, `onBlur` fires on whatever had it, `onFocus` here. Also pulls the
223
+ * X input focus to the window if the window manager gave it away.
224
+ */
225
+ focus() {
226
+ this._focusManager()?.focus(this);
227
+ return this;
228
+ }
229
+
230
+ /** Give up focus, leaving the window with nothing focused. */
231
+ blur() {
232
+ const events = this._focusManager();
233
+ if (events?.focused === this) events.focus(null);
234
+ return this;
235
+ }
236
+
237
+ /** Whether this node has the owning window's focus. */
238
+ get focused() {
239
+ return this._focusManager()?.focused === this;
240
+ }
241
+
242
+ /** Whether focus is on this node or inside it — CSS `:focus-within`. A
243
+ * `<popup>` counts as inside the node it hangs off in the JSX tree, which
244
+ * is what a modal needs to know before taking focus itself. */
245
+ get focusWithin() {
246
+ const focused = this._focusManager()?.focused;
247
+ return Boolean(focused) && this.contains(focused);
248
+ }
249
+
250
+ /** Whether `node` is this node or a descendant of it (DOM `contains`). */
251
+ contains(node) {
252
+ for (let n = node; n; n = n.parent) {
253
+ if (n === this) return true;
254
+ }
255
+ return false;
256
+ }
257
+
258
+ /** Where focus for this node lives: its own window's EventManager, or —
259
+ * inside a `<popup>`, which never receives the X input focus — the owner
260
+ * window's (see EventManager.focusManager). */
261
+ _focusManager() {
262
+ return this.root?.events?.focusManager ?? null;
263
+ }
264
+
265
+ /** Register or drop this node's focus scope to match the `trapFocus` prop.
266
+ * Idempotent: called at mount (commitMount) and on every prop update. */
267
+ _syncFocusScope() {
268
+ const events = this._focusManager();
269
+ if (!events) return;
270
+ if (this.props.trapFocus) events.pushScope(this);
271
+ else events.popScope(this);
272
+ }
273
+
274
+ /** Drawn, visible children in paint order (stable sort by zIndex). */
275
+ paintOrder() {
276
+ const drawn = this.children.filter(
277
+ (c) => DRAWN_KINDS.has(c.kind) && c.yoga && !c.hidden,
278
+ );
279
+ return drawn
280
+ .map((node, i) => ({ node, i }))
281
+ .sort(
282
+ (a, b) =>
283
+ (a.node.props.zIndex ?? 0) - (b.node.props.zIndex ?? 0) || a.i - b.i,
284
+ )
285
+ .map((e) => e.node);
286
+ }
287
+
288
+ clipsChildren() {
289
+ return this.props.overflow === 'hidden' || this.props.overflow === 'scroll';
290
+ }
291
+
292
+ containsPoint(x, y) {
293
+ return (
294
+ x >= this.abs.x &&
295
+ y >= this.abs.y &&
296
+ x < this.abs.x + this.abs.width &&
297
+ y < this.abs.y + this.abs.height
298
+ );
299
+ }
300
+
301
+ /** DOM-ish rect accessor. React DevTools' Highlighter requires host
302
+ * instances to expose getClientRects() with a non-empty rect before it
303
+ * emits showNativeHighlight — without this, hovering the tree silently
304
+ * no-ops. Anything with getClientRects is also measured at mount via
305
+ * `instance.ownerDocument.documentElement` (see ownerDocument below). */
306
+ getClientRects() {
307
+ const r = this.abs;
308
+ if (!(r.width > 0 || r.height > 0)) return [];
309
+ return [
310
+ {
311
+ x: r.x,
312
+ y: r.y,
313
+ left: r.x,
314
+ top: r.y,
315
+ width: r.width,
316
+ height: r.height,
317
+ right: r.x + r.width,
318
+ bottom: r.y + r.height,
319
+ },
320
+ ];
321
+ }
322
+
323
+ /** Front-to-back hit test. Returns the deepest hit node or null. */
324
+ hitTest(x, y) {
325
+ if (this.hidden || this.props.pointerEvents === 'none') return null;
326
+ const inside = this.containsPoint(x, y);
327
+ if (!inside && this.clipsChildren()) return null;
328
+ const order = this.paintOrder();
329
+ for (let i = order.length - 1; i >= 0; i--) {
330
+ const hit = order[i].hitTest(x, y);
331
+ if (hit) return hit;
332
+ }
333
+ return inside ? this : null;
334
+ }
335
+
336
+ absolutize(originX, originY) {
337
+ if (!this.yoga) return;
338
+ this.abs = {
339
+ x: originX + this.yoga.getComputedLeft(),
340
+ y: originY + this.yoga.getComputedTop(),
341
+ width: this.yoga.getComputedWidth(),
342
+ height: this.yoga.getComputedHeight(),
343
+ };
344
+ for (const child of this.children) {
345
+ if (!child.isWindow) child.absolutize(this.abs.x, this.abs.y);
346
+ }
347
+ }
348
+
349
+ contentBox() {
350
+ const padL =
351
+ this.yoga.getComputedPadding(Yoga.EDGE_LEFT) +
352
+ this.yoga.getComputedBorder(Yoga.EDGE_LEFT);
353
+ const padT =
354
+ this.yoga.getComputedPadding(Yoga.EDGE_TOP) +
355
+ this.yoga.getComputedBorder(Yoga.EDGE_TOP);
356
+ const padR =
357
+ this.yoga.getComputedPadding(Yoga.EDGE_RIGHT) +
358
+ this.yoga.getComputedBorder(Yoga.EDGE_RIGHT);
359
+ const padB =
360
+ this.yoga.getComputedPadding(Yoga.EDGE_BOTTOM) +
361
+ this.yoga.getComputedBorder(Yoga.EDGE_BOTTOM);
362
+ return {
363
+ x: this.abs.x + padL,
364
+ y: this.abs.y + padT,
365
+ width: Math.max(0, this.abs.width - padL - padR),
366
+ height: Math.max(0, this.abs.height - padT - padB),
367
+ };
368
+ }
369
+
370
+ paint(ctx) {
371
+ if (this.hidden) return;
372
+ this._paintBackground(ctx);
373
+ this._paintContent(ctx);
374
+ this._paintChildren(ctx);
375
+ this._paintBorder(ctx);
376
+ }
377
+
378
+ _roundedPath(ctx, radius) {
379
+ ctx.beginPath();
380
+ if (radius > 0 && typeof ctx.roundRect === 'function') {
381
+ ctx.roundRect(
382
+ this.abs.x,
383
+ this.abs.y,
384
+ this.abs.width,
385
+ this.abs.height,
386
+ radius,
387
+ );
388
+ } else {
389
+ ctx.rect(this.abs.x, this.abs.y, this.abs.width, this.abs.height);
390
+ }
391
+ }
392
+
393
+ _paintBackground(ctx) {
394
+ const { backgroundColor, borderRadius = 0 } = this.props;
395
+ if (!isPaintedColor(backgroundColor)) return;
396
+ ctx.fillStyle = backgroundColor;
397
+ if (borderRadius > 0) {
398
+ this._roundedPath(ctx, borderRadius);
399
+ ctx.fill();
400
+ } else {
401
+ ctx.fillRect(this.abs.x, this.abs.y, this.abs.width, this.abs.height);
402
+ }
403
+ }
404
+
405
+ _paintBorder(ctx) {
406
+ const { borderWidth = 0, borderColor, borderRadius = 0 } = this.props;
407
+ if (!(borderWidth > 0) || !isPaintedColor(borderColor)) return;
408
+ // dashed borders need ntk >= 3.2.0 (setLineDash); solid fallback below
409
+ const dashed =
410
+ this.props.borderStyle === 'dashed' &&
411
+ typeof ctx.setLineDash === 'function';
412
+ if (dashed) {
413
+ ctx.setLineDash([borderWidth * 2 + 2, borderWidth + 2]);
414
+ }
415
+ ctx.strokeStyle = borderColor;
416
+ ctx.lineWidth = borderWidth;
417
+ // stroke centered on the box edge inset by half the border width
418
+ const inset = borderWidth / 2;
419
+ ctx.beginPath();
420
+ if (borderRadius > 0 && typeof ctx.roundRect === 'function') {
421
+ ctx.roundRect(
422
+ this.abs.x + inset,
423
+ this.abs.y + inset,
424
+ this.abs.width - borderWidth,
425
+ this.abs.height - borderWidth,
426
+ Math.max(0, borderRadius - inset),
427
+ );
428
+ } else {
429
+ ctx.rect(
430
+ this.abs.x + inset,
431
+ this.abs.y + inset,
432
+ this.abs.width - borderWidth,
433
+ this.abs.height - borderWidth,
434
+ );
435
+ }
436
+ ctx.stroke();
437
+ if (dashed) {
438
+ ctx.setLineDash([]);
439
+ }
440
+ }
441
+
442
+ _paintContent(ctx) {}
443
+
444
+ _paintChildren(ctx) {
445
+ const order = this.paintOrder();
446
+ if (order.length === 0) return;
447
+ const clip = this.clipsChildren();
448
+ if (clip) {
449
+ ctx.save();
450
+ this._roundedPath(ctx, this.props.borderRadius ?? 0);
451
+ ctx.clip();
452
+ }
453
+ for (const child of order) child.paint(ctx);
454
+ if (clip) ctx.restore();
455
+ }
456
+ }
457
+
458
+ export class BoxNode extends Node {
459
+ constructor(props, app) {
460
+ super('box', props, app);
461
+ }
462
+ }
463
+
464
+ /**
465
+ * Downward shift that recreates CSS "half-leading". ntk's TextLayout puts
466
+ * the first baseline at exactly `ascent` and packs each line's leading
467
+ * (font line gap + any lineHeight surplus) entirely *below* the glyphs, so
468
+ * a layout drawn at the top of its measured box rides visually high —
469
+ * most noticeable centered in buttons/inputs (fonts like Helvetica carry a
470
+ * 0.5em line gap). CSS instead splits that leading evenly above and below
471
+ * the ink (see seek-oss capsize for the metrics background).
472
+ */
473
+ function halfLeading(layout) {
474
+ const last = layout.lines?.[layout.lines.length - 1];
475
+ if (!last) return 0;
476
+ return Math.max(0, (layout.height - (last.baseline + last.descent)) / 2);
477
+ }
478
+
479
+ /** Raw string/number children of <text>. */
480
+ export class TextChunkNode extends Node {
481
+ constructor(text, app) {
482
+ super('textchunk', {}, app, { yoga: false });
483
+ this.text = String(text);
484
+ }
485
+
486
+ setText(text) {
487
+ this.text = String(text);
488
+ this.parent?._textContentChanged();
489
+ this.root?.invalidate(true);
490
+ }
491
+
492
+ _textContentChanged() {
493
+ this.parent?._textContentChanged();
494
+ }
495
+ }
496
+
497
+ /**
498
+ * <text>. The outermost <text> owns a yoga node with a measure function;
499
+ * nested <text> elements are style spans (no yoga node) — the paragraph is
500
+ * laid out as one run list so wrapping spans the whole content
501
+ * (ntk TextLayout accepts [{ text, ...style overrides, color }] spans).
502
+ */
503
+ export class TextNode extends Node {
504
+ constructor(props, app, { span = false } = {}) {
505
+ super('text', props, app, { yoga: !span });
506
+ this.isSpan = span;
507
+ this._layouts = new Map();
508
+ if (this.yoga) {
509
+ this.yoga.setMeasureFunc((width, widthMode) => {
510
+ const maxWidth =
511
+ widthMode === Yoga.MEASURE_MODE_UNDEFINED ? Infinity : width;
512
+ const layout = this._layoutFor(maxWidth);
513
+ if (!layout) return { width: 0, height: 0 };
514
+ return {
515
+ width: Math.ceil(layout.width),
516
+ height: Math.ceil(layout.height),
517
+ };
518
+ });
519
+ }
520
+ }
521
+
522
+ _textContentChanged() {
523
+ if (this.isSpan) {
524
+ this.parent?._textContentChanged();
525
+ return;
526
+ }
527
+ this._layouts.clear();
528
+ if (this.yoga) this.yoga.markDirty();
529
+ }
530
+
531
+ applyProps(newProps, oldProps) {
532
+ const before = oldProps ?? this.props;
533
+ let textChanged = newProps.color !== before.color;
534
+ for (const key of TEXT_LAYOUT_PROPS) {
535
+ if (newProps[key] !== before[key]) textChanged = true;
536
+ }
537
+ if (textChanged) this._textContentChanged();
538
+ super.applyProps(newProps, oldProps);
539
+ }
540
+
541
+ collectSpans(inherited, out) {
542
+ const style = textStyleFrom(this.props, inherited);
543
+ for (const child of this.children) {
544
+ if (child.kind === 'textchunk') {
545
+ out.push({
546
+ text: child.text,
547
+ family: style.family,
548
+ size: style.size,
549
+ weight: style.weight,
550
+ style: style.style,
551
+ color: style.color,
552
+ });
553
+ } else if (child.kind === 'text') {
554
+ child.collectSpans(style, out);
555
+ }
556
+ }
557
+ return out;
558
+ }
559
+
560
+ _layoutFor(maxWidth) {
561
+ const fonts = this.app?.fonts;
562
+ if (!fonts) return null; // mock container in tests: no text metrics
563
+ const key = String(maxWidth);
564
+ let layout = this._layouts.get(key);
565
+ if (!layout) {
566
+ const spans = this.collectSpans(DEFAULT_TEXT_STYLE, []);
567
+ const base = textStyleFrom(this.props, DEFAULT_TEXT_STYLE);
568
+ layout = fonts.layout(spans, base, {
569
+ maxWidth: Number.isFinite(maxWidth) ? maxWidth : undefined,
570
+ align: this.props.textAlign,
571
+ lineHeight: this.props.lineHeight,
572
+ });
573
+ if (this._layouts.size > 32) this._layouts.clear();
574
+ this._layouts.set(key, layout);
575
+ }
576
+ return layout;
577
+ }
578
+
579
+ _paintContent(ctx) {
580
+ const content = this.contentBox();
581
+ const layout = this._layoutFor(content.width || Infinity);
582
+ if (layout) layout.draw(ctx, content.x, content.y + halfLeading(layout));
583
+ }
584
+ }
585
+
586
+ export class ImageNode extends Node {
587
+ constructor(props, app) {
588
+ super('image', props, app);
589
+ this.image = null;
590
+ this._loadToken = 0;
591
+ this._configureMeasure();
592
+ this._load(props.src);
593
+ }
594
+
595
+ _configureMeasure() {
596
+ const fixed = this.props.width != null && this.props.height != null;
597
+ if (fixed) {
598
+ this.yoga.unsetMeasureFunc();
599
+ return;
600
+ }
601
+ this.yoga.setMeasureFunc((width, widthMode, height, heightMode) => {
602
+ const natW = this.image?.width ?? 0;
603
+ const natH = this.image?.height ?? 0;
604
+ // a height alone should scale the width with it, the way an <img>
605
+ // with only a height set does — not stretch to the container
606
+ if (
607
+ this.props.width == null &&
608
+ this.props.height != null &&
609
+ heightMode !== Yoga.MEASURE_MODE_UNDEFINED &&
610
+ natH > 0
611
+ ) {
612
+ return { width: (height * natW) / natH, height };
613
+ }
614
+ let w = natW;
615
+ if (widthMode !== Yoga.MEASURE_MODE_UNDEFINED && width < w) w = width;
616
+ return { width: w, height: natW > 0 ? (w * natH) / natW : natH };
617
+ });
618
+ }
619
+
620
+ async _load(src) {
621
+ if (!src) return;
622
+ const token = ++this._loadToken;
623
+ try {
624
+ const { loadImage } = await import('ntk');
625
+ const image = await loadImage(src);
626
+ if (token !== this._loadToken || this.destroyed) return;
627
+ this.image = image;
628
+ if (this.yoga) {
629
+ this.yoga.markDirty?.();
630
+ this.root?.invalidate(true);
631
+ }
632
+ } catch (err) {
633
+ console.error(`react-x11: failed to load image ${src}:`, err.message);
634
+ }
635
+ }
636
+
637
+ applyProps(newProps, oldProps) {
638
+ const before = oldProps ?? this.props;
639
+ super.applyProps(newProps, oldProps);
640
+ this._configureMeasure();
641
+ if (newProps.src !== before.src) {
642
+ this.image = null;
643
+ this._load(newProps.src);
644
+ }
645
+ }
646
+
647
+ _paintContent(ctx) {
648
+ if (!this.image) return;
649
+ const content = this.contentBox();
650
+ ctx.drawImage(
651
+ this.image,
652
+ content.x,
653
+ content.y,
654
+ content.width,
655
+ content.height,
656
+ );
657
+ }
658
+ }
659
+
660
+ /**
661
+ * <scrollview>: a clipped viewport over its (overflowing) children. The
662
+ * scroll offset is applied during absolutize, so painting and hit testing
663
+ * see already-shifted rects. Wheel events scroll it by default (see
664
+ * EventManager); scrollTo/scrollBy are available on the ref.
665
+ */
666
+ export class ScrollViewNode extends Node {
667
+ constructor(props, app) {
668
+ super('scrollview', props, app);
669
+ this.scrollY = 0;
670
+ this.contentHeight = 0;
671
+ if (props.overflow === undefined) {
672
+ this.yoga.setOverflow(Yoga.OVERFLOW_SCROLL);
673
+ }
674
+ // yoga's default flexShrink is 0, which would size the viewport to its
675
+ // content; a scroll container must yield to the outer layout instead
676
+ if (props.flexShrink === undefined) {
677
+ this.yoga.setFlexShrink(1);
678
+ }
679
+ // …and flexShrink alone is not enough. A flex item's base size is its
680
+ // content, and yoga (unlike CSS) does not shrink items by default — so
681
+ // a window whose scrollview holds more rows than fit grew *past* the
682
+ // window, pushing the footer out of view, however small the window got.
683
+ // `flex-basis: 0` is what CSS's `flex: 1` means, and for a scroll
684
+ // container it is always what is wanted: take the space that is left,
685
+ // and let the content overflow into a scroll. It also fixes the whole
686
+ // ancestor chain at once, since the content no longer counts towards
687
+ // any of their heights.
688
+ const sized = props.height !== undefined || props.width !== undefined;
689
+ if (props.flexBasis === undefined && !sized && (props.flexGrow ?? 0) > 0) {
690
+ this.yoga.setFlexBasis(0);
691
+ }
692
+ // the CSS `min-height: 0` idiom, for the layouts flex-basis cannot save
693
+ if (props.minHeight === undefined) this.yoga.setMinHeight(0);
694
+ if (props.minWidth === undefined) this.yoga.setMinWidth(0);
695
+ }
696
+
697
+ clipsChildren() {
698
+ return true;
699
+ }
700
+
701
+ absolutize(originX, originY) {
702
+ if (!this.yoga) return;
703
+ this.abs = {
704
+ x: originX + this.yoga.getComputedLeft(),
705
+ y: originY + this.yoga.getComputedTop(),
706
+ width: this.yoga.getComputedWidth(),
707
+ height: this.yoga.getComputedHeight(),
708
+ };
709
+ let bottom = 0;
710
+ for (const child of this.children) {
711
+ if (child.yoga && !child.isWindow) {
712
+ bottom = Math.max(
713
+ bottom,
714
+ child.yoga.getComputedTop() + child.yoga.getComputedHeight(),
715
+ );
716
+ }
717
+ }
718
+ this.contentHeight =
719
+ bottom + this.yoga.getComputedPadding(Yoga.EDGE_BOTTOM);
720
+ this._resolveScrollIntoView();
721
+ this.scrollY = Math.min(
722
+ Math.max(0, this.scrollY),
723
+ Math.max(0, this.contentHeight - this.abs.height),
724
+ );
725
+ for (const child of this.children) {
726
+ if (!child.isWindow) {
727
+ child.absolutize(this.abs.x, this.abs.y - this.scrollY);
728
+ }
729
+ }
730
+ }
731
+
732
+ scrollTo(y) {
733
+ const max = Math.max(0, this.contentHeight - this.abs.height);
734
+ const next = Math.min(Math.max(0, y), max);
735
+ if (next === this.scrollY) return;
736
+ this.scrollY = next;
737
+ this.props.onScroll?.({
738
+ scrollY: next,
739
+ contentHeight: this.contentHeight,
740
+ viewportHeight: this.abs.height,
741
+ });
742
+ this.root?.invalidate(true);
743
+ }
744
+
745
+ scrollBy(dy) {
746
+ this.scrollTo(this.scrollY + dy);
747
+ }
748
+
749
+ /**
750
+ * Scroll the minimum amount that brings a descendant fully into view.
751
+ * The request is queued rather than applied immediately: absolute rects
752
+ * only exist after a layout pass, so a caller reacting to a mount (a
753
+ * list widget moving its selection, say) would otherwise measure a node
754
+ * that has no geometry yet. `absolutize` resolves it against freshly
755
+ * computed yoga positions.
756
+ */
757
+ scrollIntoView(node) {
758
+ if (!node) return;
759
+ this._scrollIntoViewTarget = node;
760
+ this.root?.invalidate(true);
761
+ }
762
+
763
+ _resolveScrollIntoView() {
764
+ const target = this._scrollIntoViewTarget;
765
+ if (!target) return;
766
+ this._scrollIntoViewTarget = null;
767
+ if (target.destroyed || !target.yoga) return;
768
+ // offset of the target within our content box, summed up the chain so
769
+ // targets nested below a direct child work too
770
+ let top = 0;
771
+ for (let n = target; n && n !== this; n = n.parent) {
772
+ if (!n.yoga) return; // not (or no longer) inside this scrollview
773
+ top += n.yoga.getComputedTop();
774
+ if (!n.parent) return;
775
+ }
776
+ const bottom = top + target.yoga.getComputedHeight();
777
+ const viewport = this.abs.height;
778
+ if (bottom > this.scrollY + viewport) this.scrollY = bottom - viewport;
779
+ if (top < this.scrollY) this.scrollY = top;
780
+ }
781
+
782
+ paint(ctx) {
783
+ super.paint(ctx);
784
+ this._paintScrollbar(ctx);
785
+ }
786
+
787
+ _paintScrollbar(ctx) {
788
+ if (this.props.scrollbar === false) return;
789
+ const viewport = this.abs.height;
790
+ if (!(this.contentHeight > viewport)) return;
791
+ const trackWidth = 6;
792
+ const thumbHeight = Math.max(
793
+ 20,
794
+ (viewport * viewport) / this.contentHeight,
795
+ );
796
+ const range = this.contentHeight - viewport;
797
+ const thumbY =
798
+ this.abs.y + (this.scrollY / range) * (viewport - thumbHeight);
799
+ const thumbX = this.abs.x + this.abs.width - trackWidth - 2;
800
+ ctx.fillStyle = this.props.scrollbarColor || 'rgba(0, 0, 0, 0.25)';
801
+ ctx.beginPath();
802
+ if (typeof ctx.roundRect === 'function') {
803
+ ctx.roundRect(thumbX, thumbY, trackWidth, thumbHeight, 3);
804
+ } else {
805
+ ctx.rect(thumbX, thumbY, trackWidth, thumbHeight);
806
+ }
807
+ ctx.fill();
808
+ }
809
+ }
810
+
811
+ /** Escape hatch: a retained node whose content is painted by props.onDraw. */
812
+ export class CanvasNode extends Node {
813
+ constructor(props, app) {
814
+ super('canvas', props, app);
815
+ }
816
+
817
+ applyProps(newProps, oldProps) {
818
+ super.applyProps(newProps, oldProps);
819
+ // onDraw is read at paint time; a new closure means new content
820
+ this.root?.invalidate(false);
821
+ }
822
+
823
+ _paintContent(ctx) {
824
+ const onDraw = this.props.onDraw;
825
+ if (typeof onDraw !== 'function') return;
826
+ ctx.save();
827
+ ctx.beginPath();
828
+ ctx.rect(this.abs.x, this.abs.y, this.abs.width, this.abs.height);
829
+ ctx.clip();
830
+ ctx.translate(this.abs.x, this.abs.y);
831
+ try {
832
+ onDraw(ctx, {
833
+ width: this.abs.width,
834
+ height: this.abs.height,
835
+ node: this,
836
+ });
837
+ } finally {
838
+ ctx.restore();
839
+ }
840
+ }
841
+ }
842
+
843
+ const XK_BACKSPACE = 0xff08;
844
+ const XK_RETURN = 0xff0d;
845
+ const XK_KP_ENTER = 0xff8d;
846
+ const XK_HOME = 0xff50;
847
+ const XK_LEFT = 0xff51;
848
+ const XK_UP = 0xff52;
849
+ const XK_RIGHT = 0xff53;
850
+ const XK_DOWN = 0xff54;
851
+ const XK_PAGE_UP = 0xff55;
852
+ const XK_PAGE_DOWN = 0xff56;
853
+ const XK_END = 0xff57;
854
+ const XK_DELETE = 0xffff;
855
+
856
+ /**
857
+ * <textinput>: single-line editable text. Caret/selection via ntk TextLayout
858
+ * prefix measurement, editing via the EventManager default-action hooks
859
+ * (user onKeyDown/onMouseDown handlers run first and can preventDefault).
860
+ * Clipboard: Ctrl+C/X/V on CLIPBOARD, X11-style middle-click paste and
861
+ * select-to-own on PRIMARY (needs ntk >= 3.2.0 app.clipboard; degrades
862
+ * gracefully without it). Controlled (`value` + `onChange`) or uncontrolled
863
+ * (`defaultValue`). Caret indices are in code points, not UTF-16 units.
864
+ */
865
+ export class TextInputNode extends Node {
866
+ constructor(props, app, kind = 'textinput') {
867
+ super(kind, props, app);
868
+ this.focusableByDefault = true;
869
+ this.defaultCursor = 'text';
870
+ this._value =
871
+ props.defaultValue != null ? String(props.defaultValue) : null;
872
+ this._caret = this._chars().length;
873
+ this._anchor = this._caret;
874
+ this._scrollX = 0;
875
+ this._focused = false;
876
+ this._caretOn = false;
877
+ this._blinkTimer = null;
878
+ this._dragging = false;
879
+ this.yoga.setMeasureFunc((width, widthMode) => {
880
+ const preferred = 150;
881
+ const w =
882
+ widthMode === Yoga.MEASURE_MODE_UNDEFINED
883
+ ? preferred
884
+ : Math.min(preferred, width);
885
+ return { width: w, height: Math.ceil(this._lineHeight()) };
886
+ });
887
+ }
888
+
889
+ get value() {
890
+ if (this.props.value != null) return String(this.props.value);
891
+ return this._value ?? '';
892
+ }
893
+
894
+ _chars() {
895
+ return Array.from(this.value);
896
+ }
897
+
898
+ _textStyle() {
899
+ return textStyleFrom(this.props, DEFAULT_TEXT_STYLE);
900
+ }
901
+
902
+ _layoutOf(text) {
903
+ const fonts = this.app?.fonts;
904
+ if (!fonts) return null;
905
+ const style = this._textStyle();
906
+ return fonts.layout(text, style);
907
+ }
908
+
909
+ _lineHeight() {
910
+ const layout = this._layoutOf('Mg');
911
+ if (layout) return layout.height;
912
+ return (this.props.fontSize ?? DEFAULT_TEXT_STYLE.size) * 1.4;
913
+ }
914
+
915
+ /** Shaped layout of the current value, cached per (value, style).
916
+ * Caret math rides ntk >= 3.3.0's TextLayout caret API, which is exact
917
+ * across kerning/shaping boundaries, bidi runs and trailing whitespace
918
+ * (replaces the prefix-width measurement this used before). */
919
+ _valueLayout() {
920
+ const fonts = this.app?.fonts;
921
+ if (!fonts) return null;
922
+ const text = this.value;
923
+ const s = this._textStyle();
924
+ const key = `${text}|${s.family}|${s.size}|${s.weight}|${s.style}`;
925
+ if (this._valueLayoutKey !== key) {
926
+ this._valueLayoutKey = key;
927
+ this._valueLayoutCache = fonts.layout(text, s);
928
+ }
929
+ return this._valueLayoutCache;
930
+ }
931
+
932
+ /** Visual caret x for a logical code-point index. */
933
+ _prefixWidth(count) {
934
+ const layout = this._valueLayout();
935
+ if (!layout) return 0;
936
+ return layout.caretPosition(count).x;
937
+ }
938
+
939
+ _selection() {
940
+ return [
941
+ Math.min(this._caret, this._anchor),
942
+ Math.max(this._caret, this._anchor),
943
+ ];
944
+ }
945
+
946
+ _selectedText() {
947
+ const [a, b] = this._selection();
948
+ return this._chars().slice(a, b).join('');
949
+ }
950
+
951
+ _repaint() {
952
+ this._caretOn = true;
953
+ this.root?.invalidate(false);
954
+ }
955
+
956
+ _commit(nextChars, caret) {
957
+ const next = nextChars.join('');
958
+ const previous = this.value;
959
+ this._caret = caret;
960
+ this._anchor = caret;
961
+ if (this.props.value == null) this._value = next;
962
+ if (next !== previous) {
963
+ this.props.onChange?.(next);
964
+ }
965
+ this._repaint();
966
+ }
967
+
968
+ /** Single-line: newlines collapse to spaces (textarea overrides). */
969
+ _normalizeInsert(text) {
970
+ return String(text).replace(/[\r\n]+/g, ' ');
971
+ }
972
+
973
+ _insert(text) {
974
+ const insert = Array.from(this._normalizeInsert(text));
975
+ if (this.props.maxLength != null) {
976
+ const room =
977
+ this.props.maxLength -
978
+ (this._chars().length - (this._selection()[1] - this._selection()[0]));
979
+ if (insert.length > room) insert.length = Math.max(0, room);
980
+ }
981
+ const chars = this._chars();
982
+ const [a, b] = this._selection();
983
+ this._commit(
984
+ [...chars.slice(0, a), ...insert, ...chars.slice(b)],
985
+ a + insert.length,
986
+ );
987
+ }
988
+
989
+ _deleteRange(from, to) {
990
+ const chars = this._chars();
991
+ this._commit([...chars.slice(0, from), ...chars.slice(to)], from);
992
+ }
993
+
994
+ _moveCaret(index, extend) {
995
+ const len = this._chars().length;
996
+ this._caret = Math.min(Math.max(0, index), len);
997
+ if (!extend) this._anchor = this._caret;
998
+ this._repaint();
999
+ }
1000
+
1001
+ _clipboardApi() {
1002
+ return this.app?.clipboard ?? null;
1003
+ }
1004
+
1005
+ _copySelection(selection = 'CLIPBOARD') {
1006
+ const text = this._selectedText();
1007
+ if (!text) return;
1008
+ this._clipboardApi()
1009
+ ?.write(text, { selection })
1010
+ .catch(() => {});
1011
+ }
1012
+
1013
+ _pasteFrom(selection = 'CLIPBOARD') {
1014
+ this._clipboardApi()
1015
+ ?.read({ selection })
1016
+ .then((text) => {
1017
+ if (!this.destroyed && text) this._insert(text);
1018
+ })
1019
+ .catch(() => {});
1020
+ }
1021
+
1022
+ // --- default actions (run after user handlers unless preventDefault) ---
1023
+
1024
+ _defaultKeyDown(ev) {
1025
+ const [a, b] = this._selection();
1026
+ const hasSelection = a !== b;
1027
+ const k = ev.keysym;
1028
+
1029
+ if (k === XK_RETURN || k === XK_KP_ENTER) {
1030
+ this.props.onSubmit?.(this.value, ev);
1031
+ return;
1032
+ }
1033
+ if (k === XK_BACKSPACE) {
1034
+ if (hasSelection) this._deleteRange(a, b);
1035
+ else if (ev.ctrlKey) this._deleteRange(this._wordBoundary(a, -1), a);
1036
+ else if (a > 0) this._deleteRange(a - 1, a);
1037
+ return;
1038
+ }
1039
+ if (k === XK_DELETE) {
1040
+ if (hasSelection) this._deleteRange(a, b);
1041
+ else if (ev.ctrlKey) this._deleteRange(a, this._wordBoundary(a, 1));
1042
+ else this._deleteRange(a, Math.min(a + 1, this._chars().length));
1043
+ return;
1044
+ }
1045
+ if (k === XK_LEFT) {
1046
+ if (ev.ctrlKey) {
1047
+ this._moveCaret(this._wordBoundary(this._caret, -1), ev.shiftKey);
1048
+ } else if (!ev.shiftKey && hasSelection) {
1049
+ this._moveCaret(a, false);
1050
+ } else {
1051
+ this._moveCaret(this._caret - 1, ev.shiftKey);
1052
+ }
1053
+ if (ev.shiftKey) this._copySelection('PRIMARY');
1054
+ return;
1055
+ }
1056
+ if (k === XK_RIGHT) {
1057
+ if (ev.ctrlKey) {
1058
+ this._moveCaret(this._wordBoundary(this._caret, 1), ev.shiftKey);
1059
+ } else if (!ev.shiftKey && hasSelection) {
1060
+ this._moveCaret(b, false);
1061
+ } else {
1062
+ this._moveCaret(this._caret + 1, ev.shiftKey);
1063
+ }
1064
+ if (ev.shiftKey) this._copySelection('PRIMARY');
1065
+ return;
1066
+ }
1067
+ if (k === XK_HOME) {
1068
+ this._moveCaret(0, ev.shiftKey);
1069
+ return;
1070
+ }
1071
+ if (k === XK_END) {
1072
+ this._moveCaret(this._chars().length, ev.shiftKey);
1073
+ return;
1074
+ }
1075
+ if (ev.ctrlKey) {
1076
+ if (ev.codepoint === 0x61 /* a */) {
1077
+ this._anchor = 0;
1078
+ this._caret = this._chars().length;
1079
+ this._repaint();
1080
+ } else if (ev.codepoint === 0x63 /* c */) {
1081
+ this._copySelection();
1082
+ } else if (ev.codepoint === 0x78 /* x */) {
1083
+ this._copySelection();
1084
+ if (hasSelection) this._deleteRange(a, b);
1085
+ } else if (ev.codepoint === 0x76 /* v */) {
1086
+ this._pasteFrom();
1087
+ }
1088
+ return;
1089
+ }
1090
+ if (ev.codepoint != null && ev.codepoint >= 0x20 && ev.codepoint !== 0x7f) {
1091
+ this._insert(String.fromCodePoint(ev.codepoint));
1092
+ }
1093
+ }
1094
+
1095
+ /** Click-to-caret: logical code-point index for a window x coordinate. */
1096
+ _indexAtX(x) {
1097
+ const layout = this._valueLayout();
1098
+ if (!layout) return this._chars().length;
1099
+ const content = this.contentBox();
1100
+ return layout.indexAt(x - content.x + this._scrollX, 0);
1101
+ }
1102
+
1103
+ /** Click-to-caret for a mouse event (textarea also uses ev.y). */
1104
+ _indexAtPoint(ev) {
1105
+ return this._indexAtX(ev.x);
1106
+ }
1107
+
1108
+ /** Word range around a code-point index (whitespace-delimited). */
1109
+ _wordRangeAt(index) {
1110
+ const chars = this._chars();
1111
+ if (chars.length === 0) return [0, 0];
1112
+ let i = Math.min(index, chars.length - 1);
1113
+ const isSpace = (c) => /\s/.test(c);
1114
+ if (isSpace(chars[i]) && i > 0) i--;
1115
+ let a = i;
1116
+ let b = i;
1117
+ while (a > 0 && !isSpace(chars[a - 1])) a--;
1118
+ while (b < chars.length && !isSpace(chars[b])) b++;
1119
+ return [a, b];
1120
+ }
1121
+
1122
+ /**
1123
+ * Caret index one word away, the way Ctrl+arrow moves in a text editor:
1124
+ * skip any run of non-word characters, then the word itself. Word
1125
+ * characters are letters, digits and underscore, so "foo-bar" is two
1126
+ * words and "foo_bar" is one.
1127
+ */
1128
+ _wordBoundary(from, dir) {
1129
+ const chars = this._chars();
1130
+ const isWord = (c) => /[\p{L}\p{N}_]/u.test(c);
1131
+ let i = Math.max(0, Math.min(from, chars.length));
1132
+ if (dir > 0) {
1133
+ while (i < chars.length && !isWord(chars[i])) i++;
1134
+ while (i < chars.length && isWord(chars[i])) i++;
1135
+ } else {
1136
+ while (i > 0 && !isWord(chars[i - 1])) i--;
1137
+ while (i > 0 && isWord(chars[i - 1])) i--;
1138
+ }
1139
+ return i;
1140
+ }
1141
+
1142
+ _defaultMouseDown(ev) {
1143
+ if (ev.button === 2) {
1144
+ // X11 middle-click: paste the PRIMARY selection at the click position
1145
+ const i = this._indexAtPoint(ev);
1146
+ this._caret = i;
1147
+ this._anchor = i;
1148
+ this._pasteFrom('PRIMARY');
1149
+ return;
1150
+ }
1151
+ const i = this._indexAtPoint(ev);
1152
+ if (ev.detail >= 3) {
1153
+ this._anchor = 0;
1154
+ this._caret = this._chars().length;
1155
+ this._ownSelection();
1156
+ return;
1157
+ }
1158
+ if (ev.detail === 2) {
1159
+ const [a, b] = this._wordRangeAt(i);
1160
+ this._anchor = a;
1161
+ this._caret = b;
1162
+ this._ownSelection();
1163
+ return;
1164
+ }
1165
+ // shift+click extends from the existing anchor rather than starting a
1166
+ // fresh selection, and keeps dragging from there
1167
+ if (ev.shiftKey) {
1168
+ this._caret = i;
1169
+ this._dragging = true;
1170
+ this._ownSelection();
1171
+ return;
1172
+ }
1173
+ this._caret = i;
1174
+ this._anchor = i;
1175
+ this._dragging = true;
1176
+ this._repaint();
1177
+ }
1178
+
1179
+ _ownSelection() {
1180
+ this._repaint();
1181
+ if (this._caret !== this._anchor) this._copySelection('PRIMARY');
1182
+ }
1183
+
1184
+ _defaultMouseDrag(ev) {
1185
+ if (!this._dragging) return;
1186
+ this._caret = this._indexAtPoint(ev);
1187
+ this._repaint();
1188
+ }
1189
+
1190
+ _defaultMouseUp() {
1191
+ if (!this._dragging) return;
1192
+ this._dragging = false;
1193
+ if (this._caret !== this._anchor) this._copySelection('PRIMARY');
1194
+ }
1195
+
1196
+ _defaultFocus() {
1197
+ this._focused = true;
1198
+ this._caretOn = true;
1199
+ this._blinkTimer = setInterval(() => {
1200
+ this._caretOn = !this._caretOn;
1201
+ this.root?.invalidate(false);
1202
+ }, 530);
1203
+ this._blinkTimer.unref?.();
1204
+ this.root?.invalidate(false);
1205
+ }
1206
+
1207
+ _defaultBlur() {
1208
+ this._focused = false;
1209
+ this._caretOn = false;
1210
+ clearInterval(this._blinkTimer);
1211
+ this._blinkTimer = null;
1212
+ this.root?.invalidate(false);
1213
+ }
1214
+
1215
+ destroySubtree() {
1216
+ clearInterval(this._blinkTimer);
1217
+ this._blinkTimer = null;
1218
+ super.destroySubtree();
1219
+ }
1220
+
1221
+ applyProps(newProps, oldProps) {
1222
+ const before = oldProps ?? this.props;
1223
+ super.applyProps(newProps, oldProps);
1224
+ const len = Array.from(
1225
+ newProps.value != null ? String(newProps.value) : (this._value ?? ''),
1226
+ ).length;
1227
+ this._caret = Math.min(this._caret, len);
1228
+ this._anchor = Math.min(this._anchor, len);
1229
+ let metricsChanged = false;
1230
+ for (const key of TEXT_LAYOUT_PROPS) {
1231
+ if (newProps[key] !== before[key]) metricsChanged = true;
1232
+ }
1233
+ if (metricsChanged) {
1234
+ this.yoga.markDirty();
1235
+ this.root?.invalidate(true);
1236
+ } else if (newProps.value !== before.value) {
1237
+ this.root?.invalidate(false);
1238
+ }
1239
+ }
1240
+
1241
+ _paintContent(ctx) {
1242
+ const fonts = this.app?.fonts;
1243
+ if (!fonts) return;
1244
+ const content = this.contentBox();
1245
+ if (content.width <= 0 || content.height <= 0) return;
1246
+
1247
+ const style = this._textStyle();
1248
+ const text = this.value;
1249
+ const isEmpty = text.length === 0;
1250
+ const shown = isEmpty ? (this.props.placeholder ?? '') : text;
1251
+ const color = isEmpty
1252
+ ? (this.props.placeholderColor ?? '#9aa0a6')
1253
+ : style.color;
1254
+ const layout = fonts.layout([{ text: shown, ...style, color }], style);
1255
+ // Center the glyph ink (ascent + descent) rather than layout.height:
1256
+ // the layout box carries the line's leading entirely below the glyphs,
1257
+ // which would push the text visually upward (see halfLeading above).
1258
+ const line = layout.lines?.[0];
1259
+ const inkHeight = line ? line.ascent + line.descent : layout.height;
1260
+ const textY = content.y + Math.max(0, (content.height - inkHeight) / 2);
1261
+ // selection/caret read better with breathing room around the glyphs
1262
+ // (a DOM input highlights the whole line box, not just the ink)
1263
+ const markPad = Math.min(3, Math.max(0, textY - content.y));
1264
+ const markY = textY - markPad;
1265
+ const markHeight = inkHeight + markPad * 2;
1266
+
1267
+ // keep the caret inside the viewport
1268
+ const caretX = this._prefixWidth(this._caret);
1269
+ const textWidth = isEmpty ? 0 : layout.width;
1270
+ if (caretX - this._scrollX > content.width - 2) {
1271
+ this._scrollX = caretX - content.width + 2;
1272
+ }
1273
+ if (caretX - this._scrollX < 0) {
1274
+ this._scrollX = caretX;
1275
+ }
1276
+ this._scrollX = Math.min(
1277
+ this._scrollX,
1278
+ Math.max(0, textWidth - content.width + 2),
1279
+ );
1280
+
1281
+ ctx.save();
1282
+ ctx.beginPath();
1283
+ ctx.rect(content.x, content.y, content.width, content.height);
1284
+ ctx.clip();
1285
+ const originX = content.x - this._scrollX;
1286
+
1287
+ const [a, b] = this._selection();
1288
+ if (this._focused && a !== b && !isEmpty) {
1289
+ const selStart = this._prefixWidth(a);
1290
+ const selEnd = this._prefixWidth(b);
1291
+ ctx.fillStyle = this.props.selectionColor ?? '#b3d4fc';
1292
+ ctx.fillRect(originX + selStart, markY, selEnd - selStart, markHeight);
1293
+ }
1294
+
1295
+ layout.draw(ctx, originX, textY);
1296
+
1297
+ if (this._focused && this._caretOn && a === b) {
1298
+ ctx.fillStyle = this.props.caretColor ?? style.color;
1299
+ ctx.fillRect(originX + caretX, markY, 1.5, markHeight);
1300
+ }
1301
+ ctx.restore();
1302
+ }
1303
+ }
1304
+
1305
+ /**
1306
+ * <textarea>: multi-line editable text on the same editing core as
1307
+ * <textinput>. Word-wraps at the content width (ntk TextLayout), Enter
1308
+ * inserts a newline (Ctrl+Enter fires onSubmit), Up/Down move the caret
1309
+ * between visual lines keeping a goal column, Home/End are wrap-aware,
1310
+ * selection spans lines, and the view scrolls vertically to follow the
1311
+ * caret (wheel scrolls too). `rows` (default 3) sets the preferred height.
1312
+ */
1313
+ export class TextAreaNode extends TextInputNode {
1314
+ constructor(props, app) {
1315
+ super(props, app, 'textarea');
1316
+ this._scrollY = 0;
1317
+ this._goalX = null;
1318
+ this.yoga.setMeasureFunc((width, widthMode) => {
1319
+ const preferred = 220;
1320
+ const w =
1321
+ widthMode === Yoga.MEASURE_MODE_UNDEFINED
1322
+ ? preferred
1323
+ : Math.min(preferred, width);
1324
+ const rows = Math.max(1, this.props.rows ?? 3);
1325
+ return { width: w, height: Math.ceil(this._lineHeight() * rows) };
1326
+ });
1327
+ }
1328
+
1329
+ /** Multi-line: preserve newlines (normalize CRLF). */
1330
+ _normalizeInsert(text) {
1331
+ return String(text).replace(/\r\n?/g, '\n');
1332
+ }
1333
+
1334
+ /** Wrapped, styled layout of the value (or placeholder), cached per
1335
+ * (text, style, width). Used for painting and all caret geometry, so
1336
+ * caret math always agrees with what is on screen. */
1337
+ _valueLayout() {
1338
+ const fonts = this.app?.fonts;
1339
+ if (!fonts) return null;
1340
+ const text = this.value;
1341
+ const isEmpty = text.length === 0;
1342
+ const shown = isEmpty ? (this.props.placeholder ?? '') : text;
1343
+ const s = this._textStyle();
1344
+ const color = isEmpty
1345
+ ? (this.props.placeholderColor ?? '#9aa0a6')
1346
+ : s.color;
1347
+ const width = this.contentBox().width || undefined;
1348
+ const key = `${width}|${color}|${shown}|${s.family}|${s.size}|${s.weight}|${s.style}`;
1349
+ if (this._valueLayoutKey !== key) {
1350
+ this._valueLayoutKey = key;
1351
+ this._valueLayoutCache = fonts.layout([{ text: shown, ...s, color }], s, {
1352
+ maxWidth: width,
1353
+ });
1354
+ }
1355
+ return this._valueLayoutCache;
1356
+ }
1357
+
1358
+ applyProps(newProps, oldProps) {
1359
+ const before = oldProps ?? this.props;
1360
+ super.applyProps(newProps, oldProps);
1361
+ if (newProps.rows !== before.rows) {
1362
+ this.yoga.markDirty();
1363
+ this.root?.invalidate(true);
1364
+ }
1365
+ }
1366
+
1367
+ _indexAtPoint(ev) {
1368
+ const layout = this._valueLayout();
1369
+ if (!layout) return this._chars().length;
1370
+ const content = this.contentBox();
1371
+ return layout.indexAt(ev.x - content.x, ev.y - content.y + this._scrollY);
1372
+ }
1373
+
1374
+ scrollBy(dy) {
1375
+ const layout = this._valueLayout();
1376
+ const content = this.contentBox();
1377
+ const max = layout ? Math.max(0, layout.height - content.height) : 0;
1378
+ const next = Math.min(Math.max(0, this._scrollY + dy), max);
1379
+ if (next === this._scrollY) return;
1380
+ this._scrollY = next;
1381
+ this.root?.invalidate(false);
1382
+ }
1383
+
1384
+ /** Visual lines that fit in the viewport — one Page keypress worth. */
1385
+ _pageLines() {
1386
+ const height = this.contentBox().height;
1387
+ const line = this._lineHeight() || 1;
1388
+ return Math.max(1, Math.floor(height / line));
1389
+ }
1390
+
1391
+ /** Caret index on an adjacent visual line, keeping the goal column. */
1392
+ _verticalMove(layout, delta) {
1393
+ const pos = layout.caretPosition(this._caret);
1394
+ const li = pos.line + delta;
1395
+ if (li < 0) {
1396
+ this._goalX = null;
1397
+ return 0;
1398
+ }
1399
+ if (li >= layout.lines.length) {
1400
+ this._goalX = null;
1401
+ return this._chars().length;
1402
+ }
1403
+ const x = this._goalX ?? pos.x;
1404
+ this._goalX = x;
1405
+ const line = layout.lines[li];
1406
+ return layout.indexAt(x, line.y + (line.ascent + line.descent) / 2);
1407
+ }
1408
+
1409
+ _defaultKeyDown(ev) {
1410
+ const k = ev.keysym;
1411
+ const layout = this._valueLayout();
1412
+
1413
+ if (k === XK_RETURN || k === XK_KP_ENTER) {
1414
+ if (ev.ctrlKey) {
1415
+ this.props.onSubmit?.(this.value, ev);
1416
+ return;
1417
+ }
1418
+ this._goalX = null;
1419
+ this._insert('\n');
1420
+ return;
1421
+ }
1422
+ if ((k === XK_UP || k === XK_DOWN) && layout && this.value.length > 0) {
1423
+ const i = this._verticalMove(layout, k === XK_UP ? -1 : 1);
1424
+ this._moveCaret(i, ev.shiftKey);
1425
+ if (ev.shiftKey) this._copySelection('PRIMARY');
1426
+ return;
1427
+ }
1428
+ if (
1429
+ (k === XK_PAGE_UP || k === XK_PAGE_DOWN) &&
1430
+ layout &&
1431
+ this.value.length > 0
1432
+ ) {
1433
+ const i = this._verticalMove(
1434
+ layout,
1435
+ this._pageLines() * (k === XK_PAGE_UP ? -1 : 1),
1436
+ );
1437
+ this._moveCaret(i, ev.shiftKey);
1438
+ if (ev.shiftKey) this._copySelection('PRIMARY');
1439
+ return;
1440
+ }
1441
+ if ((k === XK_HOME || k === XK_END) && layout && this.value.length > 0) {
1442
+ const pos = layout.caretPosition(this._caret);
1443
+ const line = layout.lines[pos.line];
1444
+ const y = line.y + (line.ascent + line.descent) / 2;
1445
+ // indexAt clamps into the line: far left = line start; just past the
1446
+ // right edge = end of visible content (before the newline)
1447
+ const i =
1448
+ k === XK_HOME
1449
+ ? layout.indexAt(-1e6, y)
1450
+ : layout.indexAt(line.x + line.width + 0.01, y);
1451
+ this._goalX = null;
1452
+ this._moveCaret(i, ev.shiftKey);
1453
+ return;
1454
+ }
1455
+ this._goalX = null;
1456
+ super._defaultKeyDown(ev);
1457
+ }
1458
+
1459
+ /** Thumb for the vertical overflow, same look as <scrollview>'s. */
1460
+ _paintScrollbar(ctx, layout) {
1461
+ if (this.props.scrollbar === false) return;
1462
+ const content = this.contentBox();
1463
+ const viewport = content.height;
1464
+ const total = layout.height;
1465
+ if (!(total > viewport)) return;
1466
+ const trackWidth = 6;
1467
+ const thumbHeight = Math.max(20, (viewport * viewport) / total);
1468
+ const range = total - viewport;
1469
+ const thumbY =
1470
+ content.y + (this._scrollY / range) * (viewport - thumbHeight);
1471
+ const thumbX = content.x + content.width - trackWidth;
1472
+ ctx.fillStyle = this.props.scrollbarColor || 'rgba(0, 0, 0, 0.25)';
1473
+ ctx.beginPath();
1474
+ if (typeof ctx.roundRect === 'function') {
1475
+ ctx.roundRect(thumbX, thumbY, trackWidth, thumbHeight, 3);
1476
+ } else {
1477
+ ctx.rect(thumbX, thumbY, trackWidth, thumbHeight);
1478
+ }
1479
+ ctx.fill();
1480
+ }
1481
+
1482
+ _paintContent(ctx) {
1483
+ const layout = this._valueLayout();
1484
+ if (!layout) return;
1485
+ const content = this.contentBox();
1486
+ if (content.width <= 0 || content.height <= 0) return;
1487
+ const isEmpty = this.value.length === 0;
1488
+
1489
+ // keep the caret line inside the viewport
1490
+ const pos = layout.caretPosition(this._caret);
1491
+ if (pos.y + pos.height - this._scrollY > content.height) {
1492
+ this._scrollY = pos.y + pos.height - content.height;
1493
+ }
1494
+ if (pos.y - this._scrollY < 0) {
1495
+ this._scrollY = pos.y;
1496
+ }
1497
+ this._scrollY = Math.min(
1498
+ this._scrollY,
1499
+ Math.max(0, layout.height - content.height),
1500
+ );
1501
+ this._scrollY = Math.max(0, this._scrollY);
1502
+
1503
+ ctx.save();
1504
+ ctx.beginPath();
1505
+ ctx.rect(content.x, content.y, content.width, content.height);
1506
+ ctx.clip();
1507
+ const originX = content.x;
1508
+ const originY = content.y - this._scrollY;
1509
+
1510
+ const [a, b] = this._selection();
1511
+ if (this._focused && a !== b && !isEmpty) {
1512
+ const posA = layout.caretPosition(a);
1513
+ const posB = layout.caretPosition(b);
1514
+ ctx.fillStyle = this.props.selectionColor ?? '#b3d4fc';
1515
+ for (let li = posA.line; li <= posB.line; li++) {
1516
+ const line = layout.lines[li];
1517
+ const x0 = li === posA.line ? posA.x : line.x;
1518
+ const x1 = li === posB.line ? posB.x : line.x + line.width;
1519
+ // a selected bare newline still shows as a sliver
1520
+ const w = Math.max(x1 - x0, 4);
1521
+ ctx.fillRect(
1522
+ originX + x0,
1523
+ originY + line.y,
1524
+ w,
1525
+ line.ascent + line.descent,
1526
+ );
1527
+ }
1528
+ }
1529
+
1530
+ layout.draw(ctx, originX, originY);
1531
+
1532
+ if (this._focused && this._caretOn && a === b) {
1533
+ ctx.fillStyle = this.props.caretColor ?? this._textStyle().color;
1534
+ ctx.fillRect(originX + pos.x, originY + pos.y, 1.5, pos.height);
1535
+ }
1536
+ // inside the clip, so the thumb is bounded by the content box
1537
+ this._paintScrollbar(ctx, layout);
1538
+ ctx.restore();
1539
+ }
1540
+ }
1541
+
1542
+ /**
1543
+ * <window>: backed by a real X11 window. Acts as the flex root and
1544
+ * paint/event root for its drawn subtree. The node is a lightweight handle
1545
+ * during the render phase — the real window is created top-down in the
1546
+ * commit phase by realize(), so every CreateWindow names its actual parent
1547
+ * from the start (no ReparentWindow, no override-redirect staging;
1548
+ * issue #4).
1549
+ */
1550
+ export class WindowNode extends Node {
1551
+ constructor(app, attributes, props) {
1552
+ super('window', props, app, { yoga: true });
1553
+ this.root = this;
1554
+ this.attributes = attributes;
1555
+ this.window = null;
1556
+ this.needsLayout = true;
1557
+ this.needsPaint = true;
1558
+ this._scheduled = false;
1559
+ this.events = new EventManager(this);
1560
+ // ids of the child windows in the order the *server* stacks them,
1561
+ // bottom to top — see _restackWindowChildren
1562
+ this._xStack = [];
1563
+ }
1564
+
1565
+ /** Create the real X11 window (commit phase only). Children windows are
1566
+ * realized against this window, then mapped before it so the whole
1567
+ * subtree appears at once when the outermost window maps. */
1568
+ realize(parentWindow) {
1569
+ if (this.window || this.destroyed) return;
1570
+ const attributes = { ...this.attributes };
1571
+ if (parentWindow) {
1572
+ attributes.parent = parentWindow;
1573
+ }
1574
+ const wnd = this.app.createWindow(attributes);
1575
+ this.window = wnd;
1576
+ wnd._reactX11Node = this;
1577
+ wnd._reactFiber = this._reactFiber;
1578
+ // windows are DevTools public instances too — see Node.getClientRects
1579
+ wnd.getClientRects ??= () => [
1580
+ { x: 0, y: 0, left: 0, top: 0, width: wnd.width, height: wnd.height },
1581
+ ];
1582
+ wnd.ownerDocument ??= DEVTOOLS_FAKE_DOCUMENT;
1583
+ this._attachWindowListeners();
1584
+ for (const child of this.children) {
1585
+ if (child.isWindow && !child.isPopup) {
1586
+ child.realize(wnd);
1587
+ if (child.window) this._xStack.push(child.window.id);
1588
+ }
1589
+ }
1590
+ this._restackWindowChildren();
1591
+ // <glarea>s mounted before the window existed own a child X window too
1592
+ this._realizeGlAreas(this);
1593
+ wnd.map?.();
1594
+ this.invalidate(true);
1595
+ }
1596
+
1597
+ /** Walk the drawn subtree and give every <glarea> its child X window. */
1598
+ _realizeGlAreas(node) {
1599
+ for (const child of node.children) {
1600
+ if (child.isWindow) continue;
1601
+ if (child.isGlArea) child.realize();
1602
+ else this._realizeGlAreas(child);
1603
+ }
1604
+ }
1605
+
1606
+ /**
1607
+ * Window-manager hints that changed since the last render (ntk >= 3.5.0).
1608
+ * Creation is handled by ntk's Window constructor — every non-event prop
1609
+ * is forwarded there as a creation attribute — so this only has to cover
1610
+ * updates.
1611
+ *
1612
+ * `sizeHints` is an object rather than flat minWidth/maxWidth props on
1613
+ * purpose: those names are yoga layout style, and a `<window>` already
1614
+ * has the confusing split where width/height are window state instead.
1615
+ */
1616
+ _applyWindowHints(next, prev) {
1617
+ const wnd = this.window;
1618
+
1619
+ if (
1620
+ next.resizable !== prev.resizable ||
1621
+ !shallowEqual(next.sizeHints, prev.sizeHints)
1622
+ ) {
1623
+ wnd.setSizeHints?.({
1624
+ ...next.sizeHints,
1625
+ ...(next.resizable === false && { resizable: false }),
1626
+ });
1627
+ }
1628
+ if (!shallowEqual(next.wmClass, prev.wmClass) && next.wmClass) {
1629
+ const c = next.wmClass;
1630
+ if (Array.isArray(c)) wnd.setClass?.(c[0], c[1]);
1631
+ else if (typeof c === 'object') wnd.setClass?.(c.instance, c.class);
1632
+ else wnd.setClass?.(c);
1633
+ }
1634
+ if (!shallowEqual(next.windowType, prev.windowType) && next.windowType) {
1635
+ wnd.setWindowType?.(next.windowType);
1636
+ }
1637
+ if (Boolean(next.alwaysOnTop) !== Boolean(prev.alwaysOnTop)) {
1638
+ wnd.setAlwaysOnTop?.(Boolean(next.alwaysOnTop));
1639
+ }
1640
+ }
1641
+
1642
+ // window geometry props are window state, not yoga style — never feed
1643
+ // width/height into the root yoga node (flush() sets them from the real
1644
+ // window size, which the user may have changed by resizing)
1645
+ _yogaProps(props) {
1646
+ if (props.width == null && props.height == null) return props;
1647
+ return { ...props, width: undefined, height: undefined };
1648
+ }
1649
+
1650
+ _attachWindowListeners() {
1651
+ const wnd = this.window;
1652
+ if (typeof wnd.on !== 'function') return;
1653
+ wnd.on('resize', (ev) => {
1654
+ this.needsLayout = true;
1655
+ this.invalidate(true);
1656
+ this.props.onResize?.(ev);
1657
+ });
1658
+ // the frame clock emits 'draw' when the backing store content is invalid
1659
+ wnd.on('draw', () => {
1660
+ this.needsPaint = true;
1661
+ this.flush();
1662
+ });
1663
+ wnd.on('expose', (ev) => {
1664
+ this.props.onExpose?.(ev);
1665
+ });
1666
+ // WM close button: with an onCloseRequest prop the window opts into the
1667
+ // WM_DELETE_WINDOW protocol and the handler decides what happens
1668
+ // (unmount, hide, quit). Without it the WM default stands (the server
1669
+ // kills the connection). Opt-in is decided at realize time.
1670
+ if (this.props.onCloseRequest && typeof wnd.setActions === 'function') {
1671
+ wnd.setActions();
1672
+ const X = this.app.X;
1673
+ if (typeof X?.InternAtom === 'function') {
1674
+ X.InternAtom(false, 'WM_DELETE_WINDOW', (err, atom) => {
1675
+ if (!err) this._wmDeleteAtom = atom;
1676
+ });
1677
+ }
1678
+ wnd.on('message', (ev) => {
1679
+ if (this._wmDeleteAtom != null && ev.data?.[0] === this._wmDeleteAtom) {
1680
+ // a WM close is a user action: discrete priority, like clicks
1681
+ runWithPriority(DiscreteEventPriority, () => {
1682
+ this.props.onCloseRequest?.(ev);
1683
+ });
1684
+ }
1685
+ });
1686
+ }
1687
+ this.events.attach();
1688
+ }
1689
+
1690
+ /** Child <window>s in the order they should stack, bottom to top: the same
1691
+ * rule drawn children paint by (later sibling on top, `zIndex` first). */
1692
+ _windowStackOrder() {
1693
+ return this.children
1694
+ .filter((c) => c.isWindow && !c.isPopup && c.window)
1695
+ .map((node, i) => ({ node, i }))
1696
+ .sort(
1697
+ (a, b) =>
1698
+ (a.node.props.zIndex ?? 0) - (b.node.props.zIndex ?? 0) || a.i - b.i,
1699
+ )
1700
+ .map((e) => e.node);
1701
+ }
1702
+
1703
+ /**
1704
+ * Make the server's stacking order match the JSX order. X stacks a new
1705
+ * window on top of its siblings, so plain mount order already comes out
1706
+ * right and this sends nothing; it costs requests only when React moves a
1707
+ * child window or a `zIndex` changes. Walking top-down and putting each
1708
+ * window directly below the one above it fixes any permutation in one
1709
+ * pass — after step i, everything from i upwards is a contiguous run in
1710
+ * the right order. Top-level windows are excluded on purpose: they are
1711
+ * the window manager's to stack, and it redirects the request anyway;
1712
+ * so are popups, which are children of the screen root wherever they sit
1713
+ * in the tree. Only `<window>` children are ordered against each other —
1714
+ * a `<glarea>`'s X window is a sibling at the server, but it belongs to
1715
+ * the drawn tree, which has no stacking relationship with them.
1716
+ */
1717
+ _restackWindowChildren() {
1718
+ const X = this.app?.X;
1719
+ if (!this.window || typeof X?.ConfigureWindow !== 'function') return;
1720
+ const stack = this._windowStackOrder();
1721
+ const ids = stack.map((c) => c.window.id);
1722
+ if (
1723
+ ids.length === this._xStack.length &&
1724
+ ids.every((id, i) => id === this._xStack[i])
1725
+ ) {
1726
+ return;
1727
+ }
1728
+ for (let i = stack.length - 2; i >= 0; i--) {
1729
+ X.ConfigureWindow(ids[i], {
1730
+ sibling: ids[i + 1],
1731
+ stackMode: STACK_BELOW,
1732
+ });
1733
+ }
1734
+ this._xStack = ids;
1735
+ }
1736
+
1737
+ insertBefore(child, beforeChild) {
1738
+ if (child.isPopup) {
1739
+ Node.prototype.insertBefore.call(this, child, beforeChild);
1740
+ return;
1741
+ }
1742
+ if (child.isWindow) {
1743
+ this._spliceChild(child, beforeChild);
1744
+ child.parent = this;
1745
+ // Initial children are realized when this window realizes; a child
1746
+ // appended to an already-realized window is created immediately,
1747
+ // top-down against its real parent — and lands on top of its
1748
+ // siblings, which _restackWindowChildren then corrects if the JSX
1749
+ // order says otherwise.
1750
+ if (this.window && !child.window) {
1751
+ child.realize(this.window);
1752
+ if (child.window) this._xStack.push(child.window.id);
1753
+ }
1754
+ // React reorders a keyed list with one insertBefore per moved child;
1755
+ // restacking once at the end of the commit skips the intermediate
1756
+ // orders, which nobody ever sees.
1757
+ pendingRestack.add(this);
1758
+ return;
1759
+ }
1760
+ Node.prototype.insertBefore.call(this, child, beforeChild);
1761
+ }
1762
+
1763
+ removeChild(child) {
1764
+ if (child.isWindow) {
1765
+ const index = this.children.indexOf(child);
1766
+ if (index !== -1) this.children.splice(index, 1);
1767
+ const id = child.window?.id;
1768
+ child.parent = null;
1769
+ child.destroySubtree();
1770
+ if (id != null) this._xStack = this._xStack.filter((w) => w !== id);
1771
+ return;
1772
+ }
1773
+ Node.prototype.removeChild.call(this, child);
1774
+ }
1775
+
1776
+ destroySubtree() {
1777
+ if (this.destroyed) return;
1778
+ this.destroyed = true;
1779
+ for (const child of this.children) child.destroySubtree();
1780
+ if (this.window && typeof this.window.destroy === 'function') {
1781
+ this.window.destroy();
1782
+ }
1783
+ this.window = null;
1784
+ if (this.yoga) {
1785
+ this.yoga.freeRecursive();
1786
+ this.yoga = null;
1787
+ }
1788
+ }
1789
+
1790
+ applyProps(newProps, oldProps) {
1791
+ const before = oldProps ?? this.props;
1792
+ this.props = newProps;
1793
+ if (Boolean(newProps.trapFocus) !== Boolean(before.trapFocus)) {
1794
+ this._syncFocusScope();
1795
+ }
1796
+ const wnd = this.window;
1797
+ if (!wnd) {
1798
+ // not realized yet: refresh creation attributes instead
1799
+ this.attributes = { ...this.attributes, ...newProps };
1800
+ return;
1801
+ }
1802
+
1803
+ if (newProps.title !== before.title) {
1804
+ wnd.setTitle?.(newProps.title || '');
1805
+ }
1806
+ // a popup is a child of the screen root, not of the node it is written
1807
+ // under, so its zIndex means nothing — and its parent here may well be
1808
+ // a drawn node with no children to stack
1809
+ if (
1810
+ (newProps.zIndex ?? 0) !== (before.zIndex ?? 0) &&
1811
+ !this.isPopup &&
1812
+ this.parent?._restackWindowChildren
1813
+ ) {
1814
+ pendingRestack.add(this.parent);
1815
+ }
1816
+ this._applyWindowHints(newProps, before);
1817
+ const geometryChanged =
1818
+ newProps.width !== before.width ||
1819
+ newProps.height !== before.height ||
1820
+ newProps.x !== before.x ||
1821
+ newProps.y !== before.y;
1822
+ if (geometryChanged) {
1823
+ if (typeof wnd.setState === 'function') {
1824
+ wnd.setState({
1825
+ x: newProps.x,
1826
+ y: newProps.y,
1827
+ width: newProps.width,
1828
+ height: newProps.height,
1829
+ });
1830
+ } else {
1831
+ if (
1832
+ newProps.width !== before.width ||
1833
+ newProps.height !== before.height
1834
+ ) {
1835
+ wnd.resize?.(newProps.width, newProps.height);
1836
+ }
1837
+ if (newProps.x !== before.x || newProps.y !== before.y) {
1838
+ wnd.move?.(newProps.x, newProps.y);
1839
+ }
1840
+ }
1841
+ }
1842
+
1843
+ const layoutChanged = applyLayoutStyle(
1844
+ this.yoga,
1845
+ this._yogaProps(newProps),
1846
+ this._yogaProps(before),
1847
+ );
1848
+ this.invalidate(
1849
+ layoutChanged || geometryChanged || paintPropsChanged(newProps, before),
1850
+ );
1851
+ }
1852
+
1853
+ setHidden(hidden) {
1854
+ if (hidden) this.window?.unmap?.();
1855
+ else this.window?.map?.();
1856
+ }
1857
+
1858
+ invalidate(layoutChanged) {
1859
+ if (this.destroyed || !this.window) return;
1860
+ if (layoutChanged) this.needsLayout = true;
1861
+ this.needsPaint = true;
1862
+ if (this._scheduled) return;
1863
+ this._scheduled = true;
1864
+ const schedule =
1865
+ typeof this.window.requestAnimationFrame === 'function'
1866
+ ? (cb) => this.window.requestAnimationFrame(cb)
1867
+ : (cb) => setImmediate(cb);
1868
+ schedule(() => {
1869
+ this._scheduled = false;
1870
+ this.flush();
1871
+ });
1872
+ }
1873
+
1874
+ flush() {
1875
+ if (this.destroyed || !this.yoga || !this.window) return;
1876
+ const width = this.window.width ?? this.props.width ?? 0;
1877
+ const height = this.window.height ?? this.props.height ?? 0;
1878
+ if (this.needsLayout) {
1879
+ this.yoga.setWidth(width);
1880
+ this.yoga.setHeight(height);
1881
+ this.yoga.calculateLayout(width, height, Yoga.DIRECTION_LTR);
1882
+ this.abs = { x: 0, y: 0, width, height };
1883
+ for (const child of this.children) {
1884
+ if (!child.isWindow) child.absolutize(0, 0);
1885
+ }
1886
+ this.needsLayout = false;
1887
+ this.needsPaint = true;
1888
+ }
1889
+ if (!this.needsPaint) return;
1890
+ this.needsPaint = false;
1891
+ if (typeof this.window.getContext !== 'function') return; // headless mock
1892
+ // ntk getContext creates a fresh context (with window-event
1893
+ // subscriptions) on every call — cache one per window
1894
+ const ctx = (this._ctx ??= this.window.getContext('2d'));
1895
+ ctx.fillStyle = this.props.backgroundColor || 'white';
1896
+ ctx.fillRect(0, 0, width, height);
1897
+ this._paintChildren(ctx);
1898
+ if (process.env.REACT_X11_DEBUG_LAYOUT) {
1899
+ this._paintDebugOverlay(ctx, this, 0);
1900
+ }
1901
+ const highlight = this._highlight;
1902
+ if (highlight && !highlight.destroyed) {
1903
+ const r = highlight.abs?.width
1904
+ ? highlight.abs
1905
+ : { x: 0, y: 0, width, height };
1906
+ ctx.fillStyle = 'rgba(41, 128, 185, 0.35)';
1907
+ ctx.fillRect(r.x, r.y, r.width, r.height);
1908
+ }
1909
+ }
1910
+
1911
+ /** DevTools hover highlight: tint a node's rect on the next paint. */
1912
+ setHighlight(node) {
1913
+ if (this._highlight === node) return;
1914
+ this._highlight = node;
1915
+ this.invalidate(false);
1916
+ }
1917
+
1918
+ /** REACT_X11_DEBUG_LAYOUT=1: outline every drawn node, color by depth. */
1919
+ _paintDebugOverlay(ctx, node, depth) {
1920
+ const colors = ['#e74c3c', '#27ae60', '#2980b9', '#8e44ad', '#f39c12'];
1921
+ for (const child of node.paintOrder()) {
1922
+ ctx.strokeStyle = colors[depth % colors.length];
1923
+ ctx.lineWidth = 1;
1924
+ ctx.beginPath();
1925
+ ctx.rect(
1926
+ child.abs.x + 0.5,
1927
+ child.abs.y + 0.5,
1928
+ child.abs.width - 1,
1929
+ child.abs.height - 1,
1930
+ );
1931
+ ctx.stroke();
1932
+ this._paintDebugOverlay(ctx, child, depth + 1);
1933
+ }
1934
+ }
1935
+ }
1936
+
1937
+ /**
1938
+ * <popup>: an override-redirect top-level window (needs ntk >= 3.1.0, which
1939
+ * forwards the attribute — sidorares/ntk#55). The window manager ignores it:
1940
+ * no decorations, no focus stealing — menus, tooltips, dropdowns. `x`/`y`
1941
+ * are screen coordinates (anchor with ev.nativeEvent.rootx/rooty or a ref's
1942
+ * abs rect + owner window position). It may appear anywhere in the JSX tree
1943
+ * but is always its own paint/event root, realized against the screen root
1944
+ * in commitMount.
1945
+ */
1946
+ export class PopupNode extends WindowNode {
1947
+ /**
1948
+ * `grab`: hold a pointer grab while this popup is up. That is how menus
1949
+ * work on X — without it a press that lands anywhere else (another app,
1950
+ * the root, or this app's own window *frame*, which belongs to the window
1951
+ * manager) never reaches us, so the menu stays open behind whatever the
1952
+ * user clicked. With the grab, that press arrives here instead, outside
1953
+ * our bounds, and `onDismiss` fires. Needs ntk >= 3.7.0; without it the
1954
+ * popup simply behaves as before.
1955
+ */
1956
+ realize(parentWindow) {
1957
+ super.realize(parentWindow);
1958
+ if (this.props.grab && !this.destroyed) {
1959
+ this.window?.grabPointer?.({}, () => {});
1960
+ }
1961
+ }
1962
+
1963
+ destroySubtree() {
1964
+ if (this.props.grab) this.window?.ungrabPointer?.();
1965
+ super.destroySubtree();
1966
+ }
1967
+
1968
+ constructor(app, attributes, props) {
1969
+ // override-redirect stays: it is what keeps the window manager from
1970
+ // repositioning or decorating a menu. The EWMH type hint is additive —
1971
+ // the spec asks for it on override-redirect windows too, so compositing
1972
+ // managers can give menus and tooltips consistent shadows/animations.
1973
+ // `windowType` overrides the default (e.g. "tooltip", "popup_menu").
1974
+ super(
1975
+ app,
1976
+ {
1977
+ ...attributes,
1978
+ overrideRedirect: true,
1979
+ windowType: attributes.windowType ?? 'dropdown_menu',
1980
+ },
1981
+ props,
1982
+ );
1983
+ this.isPopup = true;
1984
+ }
1985
+ }