react-x11 2.13.0 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/package.json +11 -8
  2. package/src/Reconciler.js +34 -21
  3. package/src/components/Select.js +8 -2
  4. package/src/events.js +8 -2
  5. package/src/glnodes.js +15 -3
  6. package/src/index.d.ts +5 -0
  7. package/src/nodes/boxpaint.js +9 -0
  8. package/src/nodes/preedit.js +64 -14
  9. package/src/ntk.js +10 -1
  10. package/src/scale.js +52 -22
  11. package/src/screencolor.js +104 -17
  12. package/src/types/system.d.ts +14 -1
  13. package/src/wayland/app.js +574 -0
  14. package/src/wayland/backendwindow.js +1199 -0
  15. package/src/wayland/clipboard.js +326 -0
  16. package/src/wayland/connection.js +485 -0
  17. package/src/wayland/context2d.js +2290 -0
  18. package/src/wayland/decorations.js +476 -0
  19. package/src/wayland/device.js +55 -0
  20. package/src/wayland/dmabuf.js +89 -0
  21. package/src/wayland/dnd.js +581 -0
  22. package/src/wayland/fdutil.js +108 -0
  23. package/src/wayland/framestyle.js +257 -0
  24. package/src/wayland/framewatch.js +190 -0
  25. package/src/wayland/glarea.js +372 -0
  26. package/src/wayland/glcontext.js +415 -0
  27. package/src/wayland/glyphatlas.js +237 -0
  28. package/src/wayland/input.js +417 -0
  29. package/src/wayland/keysymnames.js +35 -0
  30. package/src/wayland/layershell.js +363 -0
  31. package/src/wayland/outputs.js +601 -0
  32. package/src/wayland/protocols/cursor-shape-v1.json +1 -0
  33. package/src/wayland/protocols/ext-idle-notify-v1.json +1 -0
  34. package/src/wayland/protocols/ext-image-capture-source-v1.json +1 -0
  35. package/src/wayland/protocols/ext-image-copy-capture-v1.json +1 -0
  36. package/src/wayland/protocols/fractional-scale-v1.json +1 -0
  37. package/src/wayland/protocols/index.json +131 -0
  38. package/src/wayland/protocols/kde-server-decoration.json +1 -0
  39. package/src/wayland/protocols/keyboard-shortcuts-inhibit-unstable-v1.json +1 -0
  40. package/src/wayland/protocols/linux-dmabuf-v1.json +1 -0
  41. package/src/wayland/protocols/pointer-constraints-unstable-v1.json +1 -0
  42. package/src/wayland/protocols/presentation-time.json +1 -0
  43. package/src/wayland/protocols/primary-selection-unstable-v1.json +1 -0
  44. package/src/wayland/protocols/relative-pointer-unstable-v1.json +1 -0
  45. package/src/wayland/protocols/tablet-v2.json +1 -0
  46. package/src/wayland/protocols/text-input-unstable-v3.json +1 -0
  47. package/src/wayland/protocols/viewporter.json +1 -0
  48. package/src/wayland/protocols/wayland.json +1 -0
  49. package/src/wayland/protocols/wlr-layer-shell-unstable-v1.json +1 -0
  50. package/src/wayland/protocols/wlr-screencopy-unstable-v1.json +1 -0
  51. package/src/wayland/protocols/xdg-activation-v1.json +1 -0
  52. package/src/wayland/protocols/xdg-decoration-unstable-v1.json +1 -0
  53. package/src/wayland/protocols/xdg-output-unstable-v1.json +1 -0
  54. package/src/wayland/protocols/xdg-shell.json +1 -0
  55. package/src/wayland/protocols/xdg-toplevel-icon-v1.json +1 -0
  56. package/src/wayland/readback.js +99 -0
  57. package/src/wayland/screencopy.js +584 -0
  58. package/src/wayland/seat.js +584 -0
  59. package/src/wayland/shm.js +226 -0
  60. package/src/wayland/ssd.js +234 -0
  61. package/src/wayland/surface.js +141 -0
  62. package/src/wayland/swapchain.js +411 -0
  63. package/src/wayland/tablet.js +522 -0
  64. package/src/wayland/target.js +269 -0
  65. package/src/wayland/text.js +113 -0
  66. package/src/wayland/textinput.js +671 -0
  67. package/src/wayland/touch.js +284 -0
  68. package/src/wayland/window.js +854 -0
  69. package/src/wayland/xkb.js +425 -0
  70. package/src/windowstate.js +41 -1
@@ -0,0 +1,671 @@
1
+ // Input methods: `zwp_text_input_v3`, the compositor's IME talking to the
2
+ // tree's text controls.
3
+ //
4
+ // On X11 there is no input method here at all — `compose.js` turns dead
5
+ // keys and Compose sequences into characters client-side, and that is the
6
+ // whole story. Under a Wayland compositor the input method is the
7
+ // compositor's (IBus behind mutter on GNOME), and it speaks this protocol:
8
+ // the client says which surface's text field has focus, what kind of text
9
+ // it holds, where its caret is and what surrounds it; the compositor answers
10
+ // with a **preedit** (the composition the user is still typing, to be shown
11
+ // at the caret) and **commits** (text to insert). That is the same shape as
12
+ // the client-side composer's `CompositionStart/Update/End`, and it lands on
13
+ // the same machinery (`src/nodes/preedit.js`) — the tree cannot tell an IBus
14
+ // preedit from a pending dead key, and neither can an application's
15
+ // `onCompositionUpdate`.
16
+ //
17
+ // Three things about the protocol shape this file:
18
+ //
19
+ // - **Both directions are double-buffered.** Requests describing the field
20
+ // (`enable`, `set_content_type`, `set_cursor_rectangle`,
21
+ // `set_surrounding_text`) take effect on `commit`, and the compositor's
22
+ // events (`preedit_string`, `commit_string`, `delete_surrounding_text`)
23
+ // take effect on `done`, all at once and in a fixed order: delete, then
24
+ // commit, then the new preedit. `done` carries the number of commits the
25
+ // compositor had seen; a stale one is still applied, as the spec says.
26
+ // - **The state comes from the tree, at the end of a frame.** What the IME
27
+ // needs to know — which control is focused, where its caret is, what it
28
+ // holds — changes on focus moves, on edits and on caret moves, and every
29
+ // one of those repaints the field. So the backend window calls `sync()`
30
+ // once each frame after the renderer has painted, when the layout is
31
+ // current and the caret rectangle can be read off it, and `sync` sends
32
+ // only what changed. No hook into the event manager, no listener per
33
+ // field: a comparison per frame against what was last committed.
34
+ // - **The compositor filters keys the IME consumed** before `wl_keyboard.key`
35
+ // reaches us, so nothing here touches key delivery. A key that does arrive
36
+ // is one the input method declined, and the client-side composer is right
37
+ // to have its usual go at it.
38
+ //
39
+ // The enter/leave of the text input follows the seat's keyboard focus and
40
+ // names a `wl_surface`; the focused *node* is the window's focus manager's
41
+ // business, and a node in another surface of the same focus group — a field
42
+ // in the owner window while a dropdown holds the grab — is left alone until
43
+ // the keyboard comes back to its surface, which is what a toolkit does too.
44
+
45
+ import { runWithPriority, DiscreteEventPriority } from '../priority.js';
46
+
47
+ /** `REACT_X11_WAYLAND_TRACE=1`: a line on stderr per text-input event. */
48
+ const TRACE = Boolean(process.env.REACT_X11_WAYLAND_TRACE);
49
+
50
+ /** `zwp_text_input_v3.content_hint` bits. */
51
+ export const CONTENT_HINT = {
52
+ NONE: 0,
53
+ COMPLETION: 1,
54
+ SPELLCHECK: 2,
55
+ AUTO_CAPITALIZATION: 4,
56
+ LOWERCASE: 8,
57
+ UPPERCASE: 16,
58
+ TITLECASE: 32,
59
+ HIDDEN_TEXT: 64,
60
+ SENSITIVE_DATA: 128,
61
+ LATIN: 256,
62
+ MULTILINE: 512,
63
+ };
64
+
65
+ /** `zwp_text_input_v3.content_purpose`. */
66
+ export const CONTENT_PURPOSE = {
67
+ NORMAL: 0,
68
+ ALPHA: 1,
69
+ DIGITS: 2,
70
+ NUMBER: 3,
71
+ PHONE: 4,
72
+ URL: 5,
73
+ EMAIL: 6,
74
+ NAME: 7,
75
+ PASSWORD: 8,
76
+ PIN: 9,
77
+ DATE: 10,
78
+ TIME: 11,
79
+ DATETIME: 12,
80
+ TERMINAL: 13,
81
+ };
82
+
83
+ /** `zwp_text_input_v3.change_cause`. */
84
+ export const CHANGE_CAUSE = { INPUT_METHOD: 0, OTHER: 1 };
85
+
86
+ /**
87
+ * The DOM's `inputMode` values, which is the vocabulary a `<textinput>` can
88
+ * use to say what it expects, mapped onto the protocol's purposes. `text`,
89
+ * `search` and `none` are the normal purpose: they change a virtual
90
+ * keyboard's return key, which nothing here has.
91
+ */
92
+ const PURPOSE_BY_INPUT_MODE = {
93
+ numeric: CONTENT_PURPOSE.DIGITS,
94
+ decimal: CONTENT_PURPOSE.NUMBER,
95
+ tel: CONTENT_PURPOSE.PHONE,
96
+ email: CONTENT_PURPOSE.EMAIL,
97
+ url: CONTENT_PURPOSE.URL,
98
+ };
99
+
100
+ /**
101
+ * The protocol's own cap on surrounding text — a Wayland message is at most
102
+ * 4096 bytes, and the spec asks for no more than 4000 of text.
103
+ */
104
+ export const MAX_SURROUNDING_BYTES = 4000;
105
+
106
+ const utf8 = (s) => Buffer.byteLength(s, 'utf8');
107
+
108
+ /** A fresh set of the compositor's double-buffered event state. */
109
+ const freshPending = () => ({
110
+ preedit: null,
111
+ commit: null,
112
+ before: 0,
113
+ after: 0,
114
+ });
115
+
116
+ export class WaylandTextInput {
117
+ /**
118
+ * @param {import('./app.js').WaylandApp} app — or anything with `conn`,
119
+ * `seat` and a `windows` map of `wl_surface` id → backend window
120
+ */
121
+ constructor(app, manager, proxy) {
122
+ this.app = app;
123
+ this.manager = manager;
124
+ this.proxy = proxy;
125
+ /** the `wl_surface` id with text-input focus, or null */
126
+ this.entered = null;
127
+ /** commit requests sent so far — the serial `done` echoes */
128
+ this.commits = 0;
129
+ /** the serial of the last `done` */
130
+ this.doneSerial = 0;
131
+ /**
132
+ * The field the compositor has been told about, with the state last
133
+ * committed for it, or null while disabled: `{ win, node, hint, purpose,
134
+ * rect, value, caret, anchor, sendText }`.
135
+ */
136
+ this.active = null;
137
+ /** whether an IME preedit is open on `active.node` */
138
+ this.composing = false;
139
+ this._pending = freshPending();
140
+ /** the last text change was the IME's own — no `other` cause for it */
141
+ this._imeChanged = false;
142
+ this._destroyed = false;
143
+ }
144
+
145
+ /**
146
+ * Bind the manager and make the seat's text input. Null when the
147
+ * compositor has no `zwp_text_input_manager_v3` (every GNOME has one),
148
+ * which the app treats as "no input method", the way it treats any other
149
+ * absent global.
150
+ */
151
+ static async create(app) {
152
+ const manager = await app.conn.bind('zwp_text_input_manager_v3');
153
+ if (!manager || !app.seat?.seat) return null;
154
+ const proxy = manager.$.get_text_input(app.seat.seat.id);
155
+ proxy.setMaxListeners?.(0);
156
+ const input = new WaylandTextInput(app, manager, proxy);
157
+ input._wire();
158
+ return input;
159
+ }
160
+
161
+ _wire() {
162
+ const p = this.proxy;
163
+ p.on('enter', (surfaceId) => this._onEnter(surfaceId));
164
+ p.on('leave', (surfaceId) => this._onLeave(surfaceId));
165
+ // Buffered until `done`, as the spec has it: each one replaces the
166
+ // pending value, and `done` applies the set.
167
+ p.on('preedit_string', (text, cursorBegin, cursorEnd) => {
168
+ this._pending.preedit = { text: text ?? '', cursorBegin, cursorEnd };
169
+ });
170
+ p.on('commit_string', (text) => {
171
+ this._pending.commit = text ?? '';
172
+ });
173
+ p.on('delete_surrounding_text', (before, after) => {
174
+ this._pending.before = before;
175
+ this._pending.after = after;
176
+ });
177
+ p.on('done', (serial) => this._onDone(serial));
178
+ }
179
+
180
+ // ---- focus ------------------------------------------------------------
181
+
182
+ _window(surfaceId) {
183
+ return surfaceId == null ? null : (this.app.windows.get(surfaceId) ?? null);
184
+ }
185
+
186
+ /**
187
+ * The toplevel whose tree a window's focus belongs to: a popup of ours is
188
+ * its parent's, all the way up. Mutter moves text-input focus onto a
189
+ * popup that grabs, as it does the keyboard (input.js `_focusRoot`); the
190
+ * field being typed into is still the toplevel's.
191
+ */
192
+ _root(win) {
193
+ let w = win;
194
+ for (let i = 0; w?.isPopup && w.parentWindow && i < 16; i++) {
195
+ w = w.parentWindow;
196
+ }
197
+ return w;
198
+ }
199
+
200
+ /**
201
+ * A rectangle in `win`'s surface, moved into the surface that has the
202
+ * text-input focus: the same surface, unless it is one of `win`'s popups,
203
+ * against which the compositor then reads the caret.
204
+ */
205
+ _inEntered(win, rect) {
206
+ if (!rect) return rect;
207
+ const entered = this._window(this.entered);
208
+ if (!entered?.isPopup || typeof entered.offsetInRoot !== 'function')
209
+ return rect;
210
+ const off = entered.offsetInRoot();
211
+ if (off.root !== win) return rect;
212
+ return { ...rect, x: rect.x - off.x, y: rect.y - off.y };
213
+ }
214
+
215
+ _onEnter(surfaceId) {
216
+ if (TRACE) trace(`enter surface ${surfaceId}`);
217
+ this.entered = surfaceId;
218
+ const win = this._root(this._window(surfaceId));
219
+ if (!win) return;
220
+ // Now, with whatever layout the tree has — a field focused before the
221
+ // window got the keyboard should be composing from the first key — and
222
+ // again at the end of the next frame, when the caret rectangle is
223
+ // certain to be read off a current layout.
224
+ this.sync(win);
225
+ win.requestAnimationFrame?.(() => {});
226
+ }
227
+
228
+ _onLeave(surfaceId) {
229
+ if (TRACE) trace(`leave surface ${surfaceId}`);
230
+ if (this.entered === surfaceId) this.entered = null;
231
+ // The spec has the client drop its preedit here. The compositor
232
+ // considers nothing enabled on a surface it has left, but saying so
233
+ // costs one request and leaves no compositor guessing.
234
+ if (
235
+ this.active?.win === this._root(this._window(surfaceId)) ||
236
+ !this.entered
237
+ ) {
238
+ this._disable();
239
+ }
240
+ }
241
+
242
+ /**
243
+ * The focused text control of a window, or null: a node the window's
244
+ * focus manager has, that lives in *this* surface, and that composes —
245
+ * the duck type is `defaultComposition` + `textCaretRect`, which is what
246
+ * `<textinput>` and `<textarea>` implement and what a registered element
247
+ * with a caret of its own would implement to be reached the same way.
248
+ * `composes === false` is an element with an input method of its own.
249
+ */
250
+ _focusedTextControl(win) {
251
+ const node = win._reactX11Node?.events?.focusManager?.focused ?? null;
252
+ if (!node || node.destroyed) return null;
253
+ if (node.root?.window !== win) return null;
254
+ if (typeof node.defaultComposition !== 'function') return null;
255
+ if (typeof node.textCaretRect !== 'function') return null;
256
+ if (node.composes === false) return null;
257
+ return node;
258
+ }
259
+
260
+ /**
261
+ * Bring the compositor up to date about `win`'s focused field. Called by
262
+ * the backend window at the end of each frame it runs, and on `enter`.
263
+ * Cheap when nothing changed: a handful of comparisons.
264
+ */
265
+ sync(win) {
266
+ if (this._destroyed || !win) return;
267
+ if (this.entered == null) return;
268
+ if (this._root(this._window(this.entered)) !== win) return;
269
+ const node = win._destroyed ? null : this._focusedTextControl(win);
270
+ if (node !== this.active?.node) {
271
+ if (this.active) this._disable();
272
+ if (node) this._enable(win, node);
273
+ return;
274
+ }
275
+ if (node) this._update(win, node);
276
+ }
277
+
278
+ // ---- what the compositor is told ------------------------------------
279
+
280
+ /**
281
+ * The field as the protocol describes one: what kind of text, where the
282
+ * caret is in surface-local logical pixels, and the value with the caret
283
+ * and anchor in it (code points here; bytes on the wire, converted when
284
+ * sent, since the comparison that decides whether to send is cheaper on
285
+ * the strings the node already holds).
286
+ */
287
+ _describe(win, node) {
288
+ const props = node.props ?? {};
289
+ let hint = CONTENT_HINT.NONE;
290
+ let purpose = CONTENT_PURPOSE.NORMAL;
291
+ if (node.kind === 'textarea') hint |= CONTENT_HINT.MULTILINE;
292
+ // `sensitive` is the field that never lets its text reach a selection
293
+ // (docs/elements.md); an input method's word history is another place
294
+ // it must not reach, and the same field says so with one prop.
295
+ if (props.sensitive) hint |= CONTENT_HINT.SENSITIVE_DATA;
296
+ const byMode = PURPOSE_BY_INPUT_MODE[props.inputMode];
297
+ if (byMode !== undefined) purpose = byMode;
298
+ const value = typeof node.value === 'string' ? node.value : '';
299
+ const length = Array.from(value).length;
300
+ const clamp = (n, fallback) =>
301
+ Number.isInteger(n) ? Math.max(0, Math.min(n, length)) : fallback;
302
+ const caret = clamp(node._caret, length);
303
+ const anchor = clamp(node._anchor, caret);
304
+ return {
305
+ hint,
306
+ purpose,
307
+ rect: this._inEntered(win, caretRectangle(win, node, caret)),
308
+ value,
309
+ caret,
310
+ anchor,
311
+ // The surrounding text is what lets an IME reconvert a word or place
312
+ // punctuation; a secret is not offered for that.
313
+ sendText: !props.sensitive,
314
+ };
315
+ }
316
+
317
+ _enable(win, node) {
318
+ const d = this._describe(win, node);
319
+ const p = this.proxy.$;
320
+ p.enable();
321
+ // Sent with the enable, not after it: the spec lets a compositor that
322
+ // has once applied an empty cursor rectangle ignore later ones.
323
+ p.set_content_type(d.hint, d.purpose);
324
+ if (d.rect) {
325
+ p.set_cursor_rectangle(d.rect.x, d.rect.y, d.rect.width, d.rect.height);
326
+ }
327
+ if (d.sendText) {
328
+ const s = surroundingText(d.value, d.caret, d.anchor);
329
+ p.set_surrounding_text(s.text, s.cursor, s.anchor);
330
+ }
331
+ this._commit();
332
+ this.active = { win, node, ...d };
333
+ this.composing = false;
334
+ this._imeChanged = false;
335
+ this._pending = freshPending();
336
+ if (TRACE) {
337
+ trace(
338
+ `enable <${node.kind}> hint=${d.hint} purpose=${d.purpose} ` +
339
+ `rect=${d.rect ? [d.rect.x, d.rect.y, d.rect.width, d.rect.height].join(',') : 'none'}`,
340
+ );
341
+ }
342
+ }
343
+
344
+ /**
345
+ * Tell the compositor the field is gone, and the field that its
346
+ * composition is. The tree's own focus change has already cleared the
347
+ * preedit the field was drawing (`defaultBlur`); what is owed is the
348
+ * `CompositionEnd` an application that saw the start is waiting for, with
349
+ * no data, which is how an abandoned composition has always ended.
350
+ */
351
+ _disable() {
352
+ const active = this.active;
353
+ if (!active) return;
354
+ this.active = null;
355
+ this._pending = freshPending();
356
+ const { node } = active;
357
+ if (this.composing) {
358
+ this.composing = false;
359
+ const events = node.destroyed ? null : node.root?.events;
360
+ if (events) {
361
+ runWithPriority(DiscreteEventPriority, () =>
362
+ events._composition('End', node, '', null),
363
+ );
364
+ }
365
+ }
366
+ this.proxy.$.disable();
367
+ this._commit();
368
+ if (TRACE) trace(`disable <${node.kind}>`);
369
+ }
370
+
371
+ /** Send what changed about the active field since the last commit. */
372
+ _update(win, node) {
373
+ const a = this.active;
374
+ const d = this._describe(win, node);
375
+ const p = this.proxy.$;
376
+ let changed = false;
377
+ if (d.hint !== a.hint || d.purpose !== a.purpose) {
378
+ p.set_content_type(d.hint, d.purpose);
379
+ changed = true;
380
+ }
381
+ if (d.rect && !sameRect(d.rect, a.rect)) {
382
+ p.set_cursor_rectangle(d.rect.x, d.rect.y, d.rect.width, d.rect.height);
383
+ changed = true;
384
+ }
385
+ if (d.value !== a.value || d.caret !== a.caret || d.anchor !== a.anchor) {
386
+ // A change the IME did not make — typing a key it declined, a click
387
+ // placing the caret — is the IME's cue to drop or restart what it was
388
+ // composing; the cause is how it tells that apart from its own edit
389
+ // coming back as surrounding text. The initial value of the cause is
390
+ // `input_method` and it resets on every commit, so only `other` is
391
+ // ever worth sending.
392
+ if (!this._imeChanged) p.set_text_change_cause(CHANGE_CAUSE.OTHER);
393
+ if (d.sendText) {
394
+ const s = surroundingText(d.value, d.caret, d.anchor);
395
+ p.set_surrounding_text(s.text, s.cursor, s.anchor);
396
+ }
397
+ changed = true;
398
+ }
399
+ this._imeChanged = false;
400
+ if (!changed) return;
401
+ this._commit();
402
+ Object.assign(a, d);
403
+ }
404
+
405
+ _commit() {
406
+ this.proxy.$.commit();
407
+ this.commits++;
408
+ }
409
+
410
+ // ---- what the compositor says ----------------------------------------
411
+
412
+ /**
413
+ * Apply the buffered events, in the order the spec fixes: the old preedit
414
+ * gives way to the cursor, the surrounding text is deleted, the commit
415
+ * string is inserted, the new preedit goes in at the caret.
416
+ *
417
+ * A serial that is not the number of commits sent means the compositor
418
+ * answered an older state; the spec is explicit that the changes are
419
+ * applied all the same. Nothing is sent *in response* here either way —
420
+ * the field's repaint runs the next frame's `sync`, which reports the new
421
+ * surrounding text as a change of the input method's own making.
422
+ */
423
+ _onDone(serial) {
424
+ this.doneSerial = serial;
425
+ const pending = this._pending;
426
+ this._pending = freshPending();
427
+ const active = this.active;
428
+ if (TRACE) {
429
+ // what a `sensitive` field is being sent is a secret in a log too
430
+ const shown = (s) =>
431
+ active && !active.sendText
432
+ ? `<${Array.from(s).length} hidden>`
433
+ : JSON.stringify(s);
434
+ trace(
435
+ `done #${serial}${serial !== this.commits ? ` (stale, ${this.commits} sent)` : ''}` +
436
+ ` delete=${pending.before}/${pending.after}` +
437
+ ` commit=${shown(pending.commit ?? '')}` +
438
+ ` preedit=${shown(pending.preedit?.text ?? '')}`,
439
+ );
440
+ }
441
+ if (!active || active.node.destroyed) return;
442
+ const node = active.node;
443
+ const events = node.root?.events;
444
+ if (!events) return;
445
+ runWithPriority(DiscreteEventPriority, () =>
446
+ this._apply(events, node, pending, serial),
447
+ );
448
+ }
449
+
450
+ _apply(events, node, pending, serial) {
451
+ // What `nativeEvent` carries on the events this raises: there is no key
452
+ // behind an IME's commit, and the serial is what ties it back to the
453
+ // protocol trace.
454
+ const native = { type: 'text-input', serial };
455
+
456
+ // The preedit is never in the value, so deleting around the selection
457
+ // works on the value directly and leaves the composition — about to
458
+ // end — where it is drawn. `before` and `after` are bytes either side
459
+ // of the selection, whole characters only.
460
+ if (pending.before > 0 || pending.after > 0) {
461
+ const chars = Array.from(node.value ?? '');
462
+ const caret = clampIndex(node._caret, chars.length);
463
+ const anchor = clampIndex(node._anchor, caret);
464
+ const a = Math.min(caret, anchor);
465
+ const b = Math.max(caret, anchor);
466
+ const from = a - charsWithin(chars, a, -pending.before);
467
+ const to = b + charsWithin(chars, b, pending.after);
468
+ if (to > from && typeof node._deleteRange === 'function') {
469
+ node._deleteRange(from, to);
470
+ this._imeChanged = true;
471
+ }
472
+ }
473
+
474
+ // The commit string ends the composition with the text it produced —
475
+ // `defaultComposition` inserts it the way a typed character is
476
+ // inserted, through `_insert`, with `maxLength`, `onChange` and the
477
+ // undo run all in play. A commit with no preedit before it is a
478
+ // composition that started and ended in one `done`, which is what an
479
+ // emoji picker or a direct-commit engine produces, and an application
480
+ // hears the same pair of events either way.
481
+ const commit = pending.commit;
482
+ if (commit) {
483
+ if (!this.composing) events._composition('Start', node, '', native);
484
+ this.composing = false;
485
+ events._composition('End', node, commit, native);
486
+ this._imeChanged = true;
487
+ }
488
+
489
+ // The new preedit, with its cursor. The protocol counts the cursor in
490
+ // bytes into the preedit; the tree wants code points, and a hidden
491
+ // cursor (both -1) is drawn at the end, which is where a caret with no
492
+ // better information goes.
493
+ const preedit = pending.preedit;
494
+ const text = preedit?.text ?? '';
495
+ if (text) {
496
+ if (!this.composing) {
497
+ events._composition('Start', node, '', native);
498
+ this.composing = true;
499
+ }
500
+ const cursor = preeditCursor(
501
+ text,
502
+ preedit.cursorBegin,
503
+ preedit.cursorEnd,
504
+ );
505
+ events._composition('Update', node, text, native, cursor);
506
+ } else if (this.composing) {
507
+ this.composing = false;
508
+ events._composition('End', node, '', native);
509
+ }
510
+ }
511
+
512
+ destroy() {
513
+ if (this._destroyed) return;
514
+ this._destroyed = true;
515
+ this.active = null;
516
+ this.proxy.removeAllListeners?.();
517
+ try {
518
+ this.proxy.$.destroy();
519
+ } catch {
520
+ /* the connection is going */
521
+ }
522
+ }
523
+ }
524
+
525
+ // ---- helpers ------------------------------------------------------------
526
+
527
+ function trace(line) {
528
+ process.stderr.write(`react-x11 wayland: text-input ${line}\n`);
529
+ }
530
+
531
+ const clampIndex = (n, length) =>
532
+ Number.isInteger(n) ? Math.max(0, Math.min(n, length)) : length;
533
+
534
+ /**
535
+ * Where the caret is, in surface-local logical pixels — what
536
+ * `set_cursor_rectangle` wants and where the IME puts its candidate window.
537
+ * The node answers in content-relative device pixels (the rule in
538
+ * backendwindow.js), so the frame's insets are added and the scale divided
539
+ * out. A field whose text has no layout yet — no fonts, or not painted —
540
+ * offers its content box, which at least puts the popup under the field.
541
+ */
542
+ function caretRectangle(win, node, caret) {
543
+ let r = null;
544
+ try {
545
+ r = node.textCaretRect(caret);
546
+ } catch {
547
+ r = null;
548
+ }
549
+ if (!r) {
550
+ const box = node.contentBox?.() ?? node.abs;
551
+ if (!box) return null;
552
+ r = { x: box.x, y: box.y, width: 0, height: box.height };
553
+ }
554
+ const s = win.scale || 1;
555
+ const i = win.insets ?? { left: 0, top: 0 };
556
+ return {
557
+ x: Math.round(r.x / s + (i.left ?? 0)),
558
+ y: Math.round(r.y / s + (i.top ?? 0)),
559
+ width: Math.max(1, Math.round((r.width ?? 0) / s)),
560
+ height: Math.max(1, Math.round((r.height ?? 0) / s)),
561
+ };
562
+ }
563
+
564
+ function sameRect(a, b) {
565
+ return (
566
+ !!a &&
567
+ !!b &&
568
+ a.x === b.x &&
569
+ a.y === b.y &&
570
+ a.width === b.width &&
571
+ a.height === b.height
572
+ );
573
+ }
574
+
575
+ /**
576
+ * The value around the selection as `set_surrounding_text` takes it: UTF-8
577
+ * text with the cursor and anchor as byte offsets into it. The whole value
578
+ * when it fits the message; otherwise a window grown outward from the
579
+ * selection until the next character would not fit, so the cursor and the
580
+ * selection are always inside what is sent.
581
+ */
582
+ export function surroundingText(value, caret, anchor) {
583
+ const chars = Array.from(value);
584
+ let from = 0;
585
+ let to = chars.length;
586
+ if (utf8(value) > MAX_SURROUNDING_BYTES) {
587
+ const a = Math.min(caret, anchor);
588
+ const b = Math.max(caret, anchor);
589
+ from = a;
590
+ to = a;
591
+ let bytes = 0;
592
+ // the selection first, clipped at the cap if it alone is over it
593
+ while (to < b) {
594
+ const n = utf8(chars[to]);
595
+ if (bytes + n > MAX_SURROUNDING_BYTES) break;
596
+ bytes += n;
597
+ to++;
598
+ }
599
+ let grew = true;
600
+ while (grew) {
601
+ grew = false;
602
+ if (from > 0) {
603
+ const n = utf8(chars[from - 1]);
604
+ if (bytes + n <= MAX_SURROUNDING_BYTES) {
605
+ from--;
606
+ bytes += n;
607
+ grew = true;
608
+ }
609
+ }
610
+ if (to < chars.length) {
611
+ const n = utf8(chars[to]);
612
+ if (bytes + n <= MAX_SURROUNDING_BYTES) {
613
+ to++;
614
+ bytes += n;
615
+ grew = true;
616
+ }
617
+ }
618
+ }
619
+ }
620
+ const window = chars.slice(from, to);
621
+ const offset = (index) =>
622
+ utf8(
623
+ window
624
+ .slice(0, Math.max(0, Math.min(index - from, window.length)))
625
+ .join(''),
626
+ );
627
+ return {
628
+ text: window.join(''),
629
+ cursor: offset(caret),
630
+ anchor: offset(anchor),
631
+ };
632
+ }
633
+
634
+ /**
635
+ * How many whole characters fit in `bytes` of UTF-8 walking from `index` —
636
+ * forwards when `bytes` is positive, backwards when negative. A count that
637
+ * lands inside a character stops before it.
638
+ */
639
+ export function charsWithin(chars, index, bytes) {
640
+ let count = 0;
641
+ let left = Math.abs(bytes);
642
+ if (bytes < 0) {
643
+ for (let i = index - 1; i >= 0; i--) {
644
+ const n = utf8(chars[i]);
645
+ if (n > left) break;
646
+ left -= n;
647
+ count++;
648
+ }
649
+ } else {
650
+ for (let i = index; i < chars.length; i++) {
651
+ const n = utf8(chars[i]);
652
+ if (n > left) break;
653
+ left -= n;
654
+ count++;
655
+ }
656
+ }
657
+ return count;
658
+ }
659
+
660
+ /**
661
+ * The preedit cursor as the tree takes it: `{ cursorBegin, cursorEnd }` in
662
+ * code points into the preedit, or nothing for a hidden cursor.
663
+ */
664
+ export function preeditCursor(text, beginBytes, endBytes) {
665
+ if (beginBytes < 0 || endBytes < 0) return undefined;
666
+ const chars = Array.from(text);
667
+ const toChars = (bytes) => charsWithin(chars, 0, bytes);
668
+ const begin = toChars(beginBytes);
669
+ const end = Math.max(begin, toChars(endBytes));
670
+ return { cursorBegin: begin, cursorEnd: end };
671
+ }