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/LICENSE +21 -0
- package/README.md +226 -0
- package/package.json +60 -7
- package/src/ClickToComponent.js +179 -0
- package/src/DevToolsIntegration.js +106 -0
- package/src/Reconciler.js +507 -0
- package/src/components/Button.js +63 -0
- package/src/components/Canvas3D.js +28 -0
- package/src/components/Checkbox.js +69 -0
- package/src/components/Dialog.js +138 -0
- package/src/components/Menu.js +644 -0
- package/src/components/ProgressBar.js +48 -0
- package/src/components/Radio.js +95 -0
- package/src/components/Select.js +272 -0
- package/src/components/Slider.js +177 -0
- package/src/components/Switch.js +49 -0
- package/src/components/Tooltip.js +146 -0
- package/src/components/anchor.js +211 -0
- package/src/components/index.js +17 -0
- package/src/components/keys.js +21 -0
- package/src/components/theme.js +66 -0
- package/src/components/typeahead.js +56 -0
- package/src/events.js +584 -0
- package/src/geometry3d.js +223 -0
- package/src/glnodes.js +275 -0
- package/src/index.js +30 -0
- package/src/mat4.js +235 -0
- package/src/nodes.js +1985 -0
- package/src/pointer3d.js +158 -0
- package/src/priority.js +39 -0
- package/src/raycast3d.js +146 -0
- package/src/richnodes.js +436 -0
- package/src/scene3d.js +683 -0
- package/src/styles.js +189 -0
- package/.npmignore +0 -17
- package/combobox.jsx +0 -69
- package/react-x11.js +0 -119
- package/test-canvas.js +0 -98
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
|
+
}
|