react-x11 2.54.0 → 2.55.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.
@@ -512,6 +512,7 @@ import { Select } from 'react-x11';
512
512
  | `labelStyle` | the caption's style, over `text` |
513
513
  | `chevronStyle` | the chevron's style, over `textMuted` |
514
514
  | `native`, `nativeMenu` | the platform's bezel, and its menu |
515
+ | `open`, `onOpenChange` | whether the menu is down |
515
516
 
516
517
  `style` restyles the trigger and the two slots what is inside it, keeping the
517
518
  menu, keyboard, type-ahead and focus ring. A borderless select in a line of
@@ -549,6 +550,23 @@ keeps the drawn menu. Elsewhere, or on an older bridge, the menu is drawn. A
549
550
  platform menu dismisses itself (Escape, a click outside, the app losing
550
551
  focus), and the select hears a pick or nothing.
551
552
 
553
+ **Whether the menu is down** is told through `onOpenChange(open)`, each time
554
+ it drops or goes, the drawn menu or the platform's, whatever moved it. With
555
+ `open` the owner holds it, as `value` holds the option: the menu drops when
556
+ `open` goes `true` and goes when it goes `false`, and a press, a key, a pick
557
+ or a click outside asks `onOpenChange` instead. A menu that cannot drop, or
558
+ that goes by itself — the platform's dismissing itself, the trigger
559
+ scrolling out of view — is told as `false`, so an owner that keeps what it
560
+ is told keeps what is on screen; a change the owner made is not told back.
561
+ That is a browser's `select.showPicker()` and `:open`, which `<Html>` in
562
+ `@react-x11/components` answers with it:
563
+
564
+ ```jsx
565
+ const [open, setOpen] = useState(false);
566
+ <Button label="Choose" onPress={() => setOpen(true)} />
567
+ <Select value={v} options={opts} onChange={pick} open={open} onOpenChange={setOpen} />
568
+ ```
569
+
552
570
  **Behaviour.** The menu opens on the **press**, as in every desktop toolkit;
553
571
  a press while it is up closes it. Space and Enter toggle it; Escape, focus
554
572
  loss, losing the window (switching applications) or picking close it. The
package/docs/elements.md CHANGED
@@ -1360,7 +1360,56 @@ Wayland the field is also declared `sensitive_data` to the input method,
1360
1360
  with no surrounding text offered ([events.md](events.md#input-methods)).
1361
1361
 
1362
1362
  **Ref**: the node, plus `value`, `undo()` / `redo()` and `canUndo` /
1363
- `canRedo` — enough for a toolbar button beside the field.
1363
+ `canRedo` — enough for a toolbar button beside the field — and the
1364
+ selection, below.
1365
+
1366
+ ### The selection, from outside
1367
+
1368
+ The ref has a DOM input's selection API, with the DOM's names and the DOM's
1369
+ units, so a copy-link box, a search field that selects on focus or an input
1370
+ mask is written as it would be against a DOM input:
1371
+
1372
+ ```jsx
1373
+ const link = useRef(null);
1374
+ <textinput
1375
+ ref={link}
1376
+ defaultValue={url}
1377
+ onFocus={() => link.current.select()}
1378
+ />;
1379
+ // an emoji picker: an insert at the caret, and the caret after it
1380
+ const field = message.current;
1381
+ const { value, selectionStart: a, selectionEnd: b } = field;
1382
+ field.value = value.slice(0, a) + emoji + value.slice(b);
1383
+ field.setSelectionRange(a + emoji.length, a + emoji.length);
1384
+ ```
1385
+
1386
+ | on the node | |
1387
+ | ------------------------------------------- | ------------------------------------------------------------------------- |
1388
+ | `selectionStart`, `selectionEnd` | where the selection starts and ends — the caret's offset where nothing is |
1389
+ | `selectionDirection` | `'forward'`, `'backward'` or `'none'`: which end the caret is at |
1390
+ | `setSelectionRange(start, end, direction?)` | select `[start, end)`; equal offsets put a bare caret |
1391
+ | `select()` | select the whole value |
1392
+
1393
+ - **Offsets are UTF-16 code units**, as the DOM's are and as a JavaScript
1394
+ string is indexed — `value.slice(selectionStart, selectionEnd)` is what is
1395
+ selected — though `maxLength` and the caret inside the field count code
1396
+ points. One that falls inside a surrogate pair is the character's start.
1397
+ - **Clamped as the DOM clamps them**: each end to the value, a start past
1398
+ the end moved back to it. Setting `selectionStart` past the end takes the
1399
+ end with it, and `selectionEnd` before the start takes the start back.
1400
+ - **The caret is at the end**, or at the start for `'backward'`, so
1401
+ Shift+arrow goes on from there. A direction set reads back as it was set,
1402
+ `'none'` over a range included, until the user moves an end; after that
1403
+ it is where the caret is, and `'none'` for a bare one.
1404
+ - **It is the field's selection**: drawn while the field has the focus — an
1405
+ unfocused field draws none, as a browser's does — and then the one lit
1406
+ range on screen; Backspace deletes it, typing replaces it, and the next
1407
+ edit is a new undo step. What the user selects is what the getters read.
1408
+ - **It does not take PRIMARY.** On X11 PRIMARY is what the user last
1409
+ selected, for a middle click elsewhere; a field a script selects on focus
1410
+ would otherwise replace it every time it was tabbed into.
1411
+
1412
+ `<textarea>` has the same, across its lines.
1364
1413
 
1365
1414
  ### Undo
1366
1415
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.54.0",
3
+ "version": "2.55.0",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "bin": {
package/src/cocoa/app.js CHANGED
@@ -1704,11 +1704,32 @@ export class CocoaApp {
1704
1704
  * again once the WindowServer lets go of it too
1705
1705
  * (`CocoaWindow._surfaceReleased`); the tick at the end of this batch
1706
1706
  * runs the frame it was holding.
1707
+ *
1708
+ * A buffer that is no window's is a `<glarea>`'s, which waits on it the
1709
+ * same way (`watchSurfaceRelease`).
1707
1710
  */
1708
1711
  _routeSurfaceReleased(ev) {
1709
1712
  for (const wnd of this._windows.values()) {
1710
1713
  if (wnd._surfaceReleased(ev.id)) return;
1711
1714
  }
1715
+ const watcher = this._surfaceWatchers?.get(ev.id);
1716
+ if (!watcher) return;
1717
+ this._surfaceWatchers.delete(ev.id);
1718
+ watcher();
1719
+ }
1720
+
1721
+ /**
1722
+ * Call `fn` once `surface-released` names IOSurface `id`: a buffer a
1723
+ * worker's flip took off a layer that no window owns. The returned
1724
+ * function stops watching.
1725
+ */
1726
+ watchSurfaceRelease(id, fn) {
1727
+ (this._surfaceWatchers ??= new Map()).set(id, fn);
1728
+ return () => {
1729
+ if (this._surfaceWatchers?.get(id) === fn) {
1730
+ this._surfaceWatchers.delete(id);
1731
+ }
1732
+ };
1712
1733
  }
1713
1734
 
1714
1735
  _routeAccessibility(ev) {
@@ -6,7 +6,8 @@
6
6
  // X server exported, frames render into IOSurface-backed framebuffers
7
7
  // (x11-dri `createTarget`) and present by handing the IOSurface's process-
8
8
  // global id to the area's own sublayer (`setLayerContentsIOSurface`). Two
9
- // targets alternate, like any swapchain; the WindowServer composites.
9
+ // targets alternate, like any swapchain — three while a worker's flip has
10
+ // yet to let go of the buffer it replaced; the WindowServer composites.
10
11
  //
11
12
  // What GlAreaNode needs from us is the child-"window" contract it already
12
13
  // speaks (src/glnodes.js): `createWindow({ parent, … })` answering an
@@ -33,6 +34,30 @@ import { withoutActions } from './quiet.js';
33
34
  const RUNGS = ['auto', 'gl'];
34
35
  const KNOWN_RUNGS = ['auto', 'gl', 'gles', 'webgpu'];
35
36
 
37
+ /** Buffers a surface's chain may hold: the window presenter's
38
+ * `MAX_BUFFERS`. A scene drawn at the display's rate takes all four, since
39
+ * each buffer it retires is held for `HOLD_PERIODS` and a frame that
40
+ * presents late in its period retires its buffer late too; with three it
41
+ * waited for one, and a 120Hz scene ran at 80. */
42
+ const MAX_BUFFERS = 4;
43
+ /** How many display periods a buffer is taken to stay on glass after the
44
+ * flip that replaced it: the render server latches a flip at a vsync a
45
+ * period or two after the commit, and composites from the old buffer until
46
+ * then. Two buffers alternating at the display's rate drew into the one it
47
+ * was still reading — `IOSurfaceIsInUse` true at nine frames in ten — and
48
+ * now and then it composited that buffer half drawn: the scene's clear
49
+ * colour, over the whole surface, for a frame. A period was not enough
50
+ * either (one frame in seven). The window presenter asks
51
+ * `surfaceIsInUse`; these buffers are x11-dri's, which nothing asks yet. */
52
+ const HOLD_PERIODS = 1.5;
53
+ /** How long a worker's flip may take to report its release before the
54
+ * buffer it retired is drawn into anyway — the window presenter's
55
+ * `FENCE_TIMEOUT_MS`. */
56
+ const RELEASE_TIMEOUT_MS = 100;
57
+ /** A chain past two buffers gives the rest back after this long without a
58
+ * frame — the window presenter's `TRIM_AFTER_MS`. */
59
+ const TRIM_AFTER_MS = 1000;
60
+
36
61
  let driPromise = null;
37
62
  function loadDri() {
38
63
  if (!driPromise) {
@@ -292,6 +317,11 @@ export class CocoaGLArea {
292
317
  function createGLAreaContext(runtime, area, config) {
293
318
  const native = area._native;
294
319
  const ctx = Object.create(runtime.gl);
320
+ // The chain: every buffer this surface draws in, `{ target, released,
321
+ // offGlassAt, unwatch }`. `front` is the one the last swap put on glass,
322
+ // and `back` the one the frame being drawn goes into, from `makeCurrent`
323
+ // to `SwapBuffers`.
324
+ let buffers = [];
295
325
  let front = null;
296
326
  let back = null;
297
327
  let width = 0;
@@ -301,20 +331,89 @@ function createGLAreaContext(runtime, area, config) {
301
331
  // millisecond before its own timer still reads the true state.
302
332
  let gateOpenAt = -Infinity;
303
333
  let gateTimer = null;
334
+ let releaseTimer = null;
335
+ let trimTimer = null;
336
+ let swappedAt = -Infinity;
304
337
  let destroyed = false;
338
+ // The frame clock's time, which `canRender` is asked on and a hold is
339
+ // counted on: the window's clock hands out the slot a frame was due at,
340
+ // and a test drives it by hand. `skew` is how far it was from
341
+ // `performance.now()` when it was last asked.
342
+ let skew = 0;
343
+ const clock = () => performance.now() + skew;
344
+
345
+ const size = () => [
346
+ Math.max(1, area.rect?.width ?? 1),
347
+ Math.max(1, area.rect?.height ?? 1),
348
+ ];
349
+
350
+ const drop = (buffer) => {
351
+ buffer.unwatch?.();
352
+ buffer.target.destroy();
353
+ };
305
354
 
355
+ // A new size is a new chain, made a buffer at a time as frames take them.
356
+ // Nothing of the old one is drawn into again, so nothing waits for it.
306
357
  const ensureTargets = () => {
307
- const w = Math.max(1, area.rect?.width ?? 1);
308
- const h = Math.max(1, area.rect?.height ?? 1);
309
- if (front && width === w && height === h) return;
310
- front?.destroy();
311
- back?.destroy();
312
- front = runtime.ctx.createTarget(w, h);
313
- back = runtime.ctx.createTarget(w, h);
358
+ const [w, h] = size();
359
+ if (width === w && height === h) return;
360
+ for (const buffer of buffers) drop(buffer);
361
+ buffers = [];
362
+ front = back = null;
314
363
  width = w;
315
364
  height = h;
316
365
  };
317
366
 
367
+ /**
368
+ * Of the buffers off glass, the one a frame may draw in now: one the
369
+ * flip that retired it has let go of, and of those the one off glass
370
+ * longest, which the WindowServer has had longest to stop reading.
371
+ * `'new'` while the chain has room for another. Past that, one whose
372
+ * release is overdue. Else null: the frame waits for a release.
373
+ */
374
+ const freeBuffer = (now) => {
375
+ const hold = runtime.frameInterval * HOLD_PERIODS;
376
+ let free = null;
377
+ let overdue = null;
378
+ for (const buffer of buffers) {
379
+ if (buffer === front) continue;
380
+ if (buffer.released && now - buffer.offGlassAt >= hold) {
381
+ if (!free || buffer.offGlassAt < free.offGlassAt) free = buffer;
382
+ } else if (now - buffer.offGlassAt >= RELEASE_TIMEOUT_MS) {
383
+ if (!overdue || buffer.offGlassAt < overdue.offGlassAt) {
384
+ overdue = buffer;
385
+ }
386
+ }
387
+ }
388
+ if (free) return free;
389
+ if (buffers.length < MAX_BUFFERS) return 'new';
390
+ return overdue;
391
+ };
392
+
393
+ const takeBack = () => {
394
+ const found = freeBuffer(clock());
395
+ if (found && found !== 'new') return found;
396
+ if (found === 'new') {
397
+ const buffer = {
398
+ target: runtime.ctx.createTarget(width, height),
399
+ released: true,
400
+ offGlassAt: -Infinity,
401
+ unwatch: null,
402
+ };
403
+ buffers.push(buffer);
404
+ return buffer;
405
+ }
406
+ // Asked to draw with nothing free — by a caller that did not ask
407
+ // `canRender` first: the buffer off glass longest, which is the one a
408
+ // chain of two always drew into.
409
+ let oldest = null;
410
+ for (const buffer of buffers) {
411
+ if (buffer === front) continue;
412
+ if (!oldest || buffer.offGlassAt < oldest.offGlassAt) oldest = buffer;
413
+ }
414
+ return oldest;
415
+ };
416
+
318
417
  ctx.backend = 'direct';
319
418
  ctx.config = config;
320
419
  ctx.glVersion = runtime.glVersion;
@@ -336,12 +435,30 @@ function createGLAreaContext(runtime, area, config) {
336
435
  // are on one grid: the clock cannot take a frame the gate would refuse,
337
436
  // because both are the same instant, and the gate still never lets two
338
437
  // frames into one period.
339
- ctx.canRender = (now = performance.now()) => !destroyed && now >= gateOpenAt;
438
+ //
439
+ // And a period is not long enough to stay off the buffer the last flip
440
+ // retired: the render server latches a flip a period or two after it is
441
+ // committed, and reads the buffer it replaces until then — on a worker,
442
+ // where the flip waits for the UI thread, until later still. A frame
443
+ // drawn into it was now and then composited half drawn: the scene's
444
+ // clear colour over the whole surface, for a frame, as a map settled
445
+ // after a zoom. So a frame takes a buffer that has been off glass for
446
+ // `HOLD_PERIODS` and, on a worker, released (`freeBuffer`), and the gate
447
+ // is shut while there is none and no room in the chain for another.
448
+ ctx.canRender = (now = performance.now()) => {
449
+ skew = now - performance.now();
450
+ if (destroyed || now < gateOpenAt) return false;
451
+ const [w, h] = size();
452
+ if (back || width !== w || height !== h || freeBuffer(now)) return true;
453
+ armRelease();
454
+ return false;
455
+ };
340
456
 
341
457
  ctx.makeCurrent = () => {
342
458
  if (destroyed) return;
343
459
  ensureTargets();
344
- runtime.ctx.bindTarget(back);
460
+ back ??= takeBack();
461
+ runtime.ctx.bindTarget(back.target);
345
462
  };
346
463
 
347
464
  // WebGL's "null means the default framebuffer" — and this surface's
@@ -349,7 +466,82 @@ function createGLAreaContext(runtime, area, config) {
349
466
  // a swapchain like this is nothing at all. A scene that renders through
350
467
  // its own FBO and unbinds at the end (the SSAA pattern) lands back here.
351
468
  ctx.bindFramebuffer = (target, fb) => {
352
- runtime.gl.bindFramebuffer(target, fb == null ? (back?.fbo ?? 0) : fb);
469
+ runtime.gl.bindFramebuffer(
470
+ target,
471
+ fb == null ? (back?.target.fbo ?? 0) : fb,
472
+ );
473
+ };
474
+
475
+ /**
476
+ * The buffer a worker's flip just took off glass is held until the bridge
477
+ * says the flip applied (`surface-released`, windowkit/appkit#52) — the
478
+ * window presenter's fence, for this surface's own buffers. The flip
479
+ * happened then, so `HOLD_PERIODS` counts from then; a frame refused
480
+ * meanwhile is asked for again once that is over.
481
+ */
482
+ const awaitRelease = (buffer) => {
483
+ buffer.unwatch?.();
484
+ buffer.released = false;
485
+ buffer.unwatch = area.app.watchSurfaceRelease(
486
+ buffer.target.iosurfaceId,
487
+ () => {
488
+ buffer.unwatch = null;
489
+ buffer.released = true;
490
+ buffer.offGlassAt = clock();
491
+ if (!destroyed) armRelease();
492
+ },
493
+ );
494
+ };
495
+
496
+ // A frame refused for want of a buffer is asked for again when the first
497
+ // one frees: its hold over, or its release overdue — a release that
498
+ // never comes stops holding the frames after `RELEASE_TIMEOUT_MS`.
499
+ let releaseDue = Infinity;
500
+ const armRelease = () => {
501
+ if (destroyed) return;
502
+ const hold = runtime.frameInterval * HOLD_PERIODS;
503
+ let due = Infinity;
504
+ for (const buffer of buffers) {
505
+ if (buffer === front) continue;
506
+ const wait = buffer.released ? hold : RELEASE_TIMEOUT_MS;
507
+ due = Math.min(due, buffer.offGlassAt + wait);
508
+ }
509
+ if (due === Infinity || (releaseTimer && releaseDue <= due)) return;
510
+ if (releaseTimer) clearTimeout(releaseTimer);
511
+ releaseDue = due;
512
+ releaseTimer = setTimeout(
513
+ () => {
514
+ releaseTimer = null;
515
+ if (!destroyed) ctx.onFrameAvailable?.();
516
+ },
517
+ Math.max(1, Math.ceil(due - clock())),
518
+ );
519
+ releaseTimer.unref?.();
520
+ };
521
+
522
+ // A chain that grew past two gives the rest back once the surface has
523
+ // gone `TRIM_AFTER_MS` without a frame: the buffers past two are what a
524
+ // scene drawn at the display's rate needs, and a still one keeps what it
525
+ // always had, the buffer on glass and one to draw the next frame in.
526
+ const armTrim = () => {
527
+ if (trimTimer || destroyed) return;
528
+ trimTimer = setTimeout(() => {
529
+ trimTimer = null;
530
+ if (destroyed || buffers.length <= 2) return;
531
+ if (clock() - swappedAt < TRIM_AFTER_MS) {
532
+ armTrim();
533
+ return;
534
+ }
535
+ const spare = buffers
536
+ .filter((b) => b !== front && b !== back && b.released)
537
+ .sort((a, b) => a.offGlassAt - b.offGlassAt)
538
+ .slice(0, buffers.length - 2);
539
+ for (const buffer of spare) drop(buffer);
540
+ buffers = buffers.filter((b) => !spare.includes(b));
541
+ // one still held keeps its place until its release comes
542
+ if (buffers.length > 2) armTrim();
543
+ }, TRIM_AFTER_MS);
544
+ trimTimer.unref?.();
353
545
  };
354
546
 
355
547
  // A frame refused by the gate has nothing else to wake it, so the gate
@@ -375,26 +567,36 @@ function createGLAreaContext(runtime, area, config) {
375
567
  ctx.SwapBuffers = () => {
376
568
  if (destroyed || !back) return;
377
569
  runtime.gl.flush();
378
- native.setLayerContentsIOSurface(area.layer, back.iosurfaceId);
379
- const shown = back;
380
- back = front;
381
- front = shown;
570
+ native.setLayerContentsIOSurface(area.layer, back.target.iosurfaceId);
571
+ const retired = front;
572
+ front = back;
573
+ back = null;
574
+ swappedAt = clock();
575
+ if (retired && retired !== front) {
576
+ retired.offGlassAt = swappedAt;
577
+ // A pump-mode flip is applied in the call; a worker's when the UI
578
+ // thread gets to it, and until then `retired` is still on glass.
579
+ if (area.app._threaded) awaitRelease(retired);
580
+ }
382
581
  // one display period from the frame's slot, not from this moment
383
582
  gateOpenAt = area.nextFrameAt(runtime.frameInterval);
384
583
  armGate();
584
+ if (buffers.length > 2) armTrim();
385
585
  };
386
586
 
387
587
  ctx._resized = () => {
388
588
  // targets are rebuilt lazily on the next makeCurrent, so a resize
389
- // storm costs two allocations per drawn frame, not per notify
589
+ // storm costs one allocation per drawn frame, not per notify
390
590
  };
391
591
 
392
592
  ctx._destroy = () => {
393
593
  if (destroyed) return;
394
594
  destroyed = true;
395
595
  if (gateTimer) clearTimeout(gateTimer);
396
- front?.destroy();
397
- back?.destroy();
596
+ if (releaseTimer) clearTimeout(releaseTimer);
597
+ if (trimTimer) clearTimeout(trimTimer);
598
+ for (const buffer of buffers) drop(buffer);
599
+ buffers = [];
398
600
  front = back = null;
399
601
  };
400
602
 
@@ -329,6 +329,16 @@ function Option({
329
329
  * what a browser does with a `<select>` a page styled, and `false` keeps the
330
330
  * drawn one. A drawn trigger's menu is set at the caption's size, in the
331
331
  * family its style names when the system has it.
332
+ *
333
+ * `onOpenChange(open)` is told each time the menu goes down or comes up,
334
+ * the drawn one or the platform's, whatever moved it — the user, a pick,
335
+ * the trigger scrolling out of view, the platform's menu dismissing itself.
336
+ * `open` puts the menu in its owner's hands, as `value` puts the option:
337
+ * the menu drops when it goes `true` and comes up when it goes `false`, and
338
+ * what the user does to open or close it asks `onOpenChange` instead of
339
+ * doing it. A menu that cannot drop — a trigger not in a window — or that
340
+ * goes by itself is told as `false`, so an owner that keeps what it is told
341
+ * keeps what is on screen. Left out, the select keeps its own.
332
342
  */
333
343
  export function Select({
334
344
  value,
@@ -338,6 +348,8 @@ export function Select({
338
348
  placeholder = 'Select…',
339
349
  native,
340
350
  nativeMenu,
351
+ open: openProp,
352
+ onOpenChange,
341
353
  style,
342
354
  labelStyle,
343
355
  chevronStyle,
@@ -444,7 +456,8 @@ export function Select({
444
456
  const openPlatformMenu = () => {
445
457
  const node = triggerRef.current;
446
458
  const wnd = node?.root?.window;
447
- if (!node || !wnd || platformRef.current) return;
459
+ if (platformRef.current) return true;
460
+ if (!node || !wnd) return false;
448
461
  const chosen = normalized.findIndex((o) => o.value === value);
449
462
  const family = menuFamily(flattenStyle(labelStyle).fontFamily);
450
463
  setPlatformOpen(true);
@@ -471,22 +484,64 @@ export function Select({
471
484
  if (option && option.value !== value) emit(option.value);
472
485
  },
473
486
  );
487
+ return true;
474
488
  };
489
+ /** Drop the menu, whichever it is; whether it is down. */
475
490
  const openMenu = () => {
476
- if (platformMenu) {
477
- openPlatformMenu();
478
- return;
479
- }
491
+ if (platformMenu) return openPlatformMenu();
480
492
  const node = triggerRef.current;
481
- if (!node) return;
493
+ if (!node) return false;
482
494
  const rect = measureAnchor(menuAnchorOptions());
483
- if (!rect) return;
495
+ if (!rect) return false;
484
496
  setAnchor(rect);
485
497
  const selected = normalized.findIndex((o) => o.value === value);
486
498
  setActiveIndex(selected >= 0 ? selected : 0);
487
499
  setOpen(true);
500
+ return true;
501
+ };
502
+
503
+ // Whether the menu is down is the select's own, or with `open` its
504
+ // owner's. `told` is what the owner knows: what it last said through
505
+ // `open`, or what it was last told through `onOpenChange` — so each
506
+ // change is told once, and one the owner made itself is not told back.
507
+ const controlled = openProp !== undefined;
508
+ const told = useRef(false);
509
+ const said = useRef(false);
510
+ const openChange = useRef(onOpenChange);
511
+ openChange.current = onOpenChange;
512
+ const tell = (next) => {
513
+ if (told.current === next) return;
514
+ told.current = next;
515
+ openChange.current?.(next);
516
+ };
517
+ /** What the user, or the menu's own dismissal, asks for: done, or with
518
+ * `open` asked of the owner. */
519
+ const ask = (next) => {
520
+ if (!controlled) {
521
+ if (next) openMenu();
522
+ else close();
523
+ return;
524
+ }
525
+ tell(next);
488
526
  };
489
- const toggle = () => (expanded ? close() : openMenu());
527
+ // The menu follows what the owner says as it changes, and what it comes
528
+ // to is told: down, up, or up again by itself. One effect does both: in
529
+ // two, the pass in which `open` changed told the owner back the state
530
+ // from before the change, which the menu had not followed yet.
531
+ useEffect(() => {
532
+ if (controlled && openProp !== said.current) {
533
+ said.current = openProp;
534
+ told.current = openProp;
535
+ if (openProp && !expanded) {
536
+ if (!openMenu()) tell(false);
537
+ } else if (!openProp && expanded) close();
538
+ return;
539
+ }
540
+ tell(expanded);
541
+ }, [controlled, openProp, expanded]);
542
+
543
+ const toggle = () => ask(!expanded);
544
+ const dismiss = () => ask(false);
490
545
  // a menu still down when the select goes is taken with it
491
546
  useEffect(() => () => platformRef.current?.(), []);
492
547
 
@@ -498,15 +553,15 @@ export function Select({
498
553
  // following it there — a popup is a real window, not something clipped
499
554
  // by the trigger's ancestors, so it would otherwise hang over content it
500
555
  // no longer points at.
501
- useAnchorTracking(triggerRef, open, menuAnchorOptions, setAnchor, close);
556
+ useAnchorTracking(triggerRef, open, menuAnchorOptions, setAnchor, dismiss);
502
557
  // and shut it when the whole window loses focus: the trigger keeps the
503
558
  // focus it has, so its own `onBlur` never fires for this
504
- useDismissOnWindowBlur(triggerRef, open, close);
559
+ useDismissOnWindowBlur(triggerRef, open, dismiss);
505
560
 
506
561
  const emit = (next) => onChange?.(changeEvent('select-one', name, next));
507
562
 
508
563
  const pick = (option) => {
509
- close();
564
+ dismiss();
510
565
  if (option.value !== value) emit(option.value);
511
566
  };
512
567
 
@@ -520,12 +575,12 @@ export function Select({
520
575
  const count = normalized.length;
521
576
  switch (ev.keysym) {
522
577
  case XK_ESCAPE:
523
- if (open) close();
578
+ if (open) dismiss();
524
579
  return;
525
580
  case XK_DOWN:
526
581
  case XK_UP:
527
582
  if (open) move(ev.keysym === XK_DOWN ? 1 : -1);
528
- else openMenu();
583
+ else ask(true);
529
584
  return;
530
585
  case XK_HOME:
531
586
  case XK_END:
@@ -589,7 +644,7 @@ export function Select({
589
644
  onFocus: () => setFocused(true),
590
645
  onBlur: () => {
591
646
  setFocused(false);
592
- close();
647
+ dismiss();
593
648
  },
594
649
  onKeyDown,
595
650
  ...boxProps,
@@ -721,7 +776,7 @@ export function Select({
721
776
  // control, not the bare `<popup>` default of `popup_menu`
722
777
  windowType: 'dropdown_menu',
723
778
  grab: true,
724
- onDismiss: close,
779
+ onDismiss: dismiss,
725
780
  // ARGB where the display has it, so the corners the sheet gives up
726
781
  // are the desktop rather than a colour — as the menus are. The
727
782
  // window paints nothing itself when it can be seen through: the
package/src/events.js CHANGED
@@ -2040,6 +2040,13 @@ export class EventManager {
2040
2040
  * Asks `isScroller()` rather than whether the method exists: every `<box>`
2041
2041
  * carries `scrollIntoView` now, and one that is not a scroll container
2042
2042
  * would end the walk without revealing anything.
2043
+ *
2044
+ * And it ends at the node's window. A `<popup>` hangs off a node of its
2045
+ * owner's tree, but it is a window of its own on the screen: no pane in
2046
+ * the owner scrolls it, and the pane above it measured the popup's
2047
+ * content as if it were laid out at the popup's parent — so the edit
2048
+ * menu, focusing its rows, scrolled a document in a pane to its top on
2049
+ * every right-click.
2043
2050
  */
2044
2051
  _scrollIntoView(node) {
2045
2052
  for (let n = node.parent; n; n = n.parent) {
@@ -2047,6 +2054,7 @@ export class EventManager {
2047
2054
  n.scrollIntoView(node);
2048
2055
  return;
2049
2056
  }
2057
+ if (n.isWindow) return;
2050
2058
  }
2051
2059
  }
2052
2060
 
@@ -696,11 +696,19 @@ function PaneHostView({
696
696
  const node = containerRef.current;
697
697
  if (!s || !node) return;
698
698
  const sc = node.scale ?? 1;
699
+ // Where the pointer is on the screen, which is what `rootx`/`rooty` are
700
+ // on every backend, and what a `<ContextMenu>` in the pane opens its
701
+ // popup at (`openAt`, components/Menu.js). The window's origin plus the
702
+ // window point: the same sum the pane is told its own corner by
703
+ // (`syncSize` below), so the pane's `x` plus its corner is this. Sent
704
+ // the window point, a pane's menu opened up and to the left of the
705
+ // pointer by as far as the window was from the screen's corner.
706
+ const origin = node.root?.window?._screenOrigin ?? { x: 0, y: 0 };
699
707
  const payload = {
700
708
  x: Math.max(0, Math.round(ev.x * sc - node.abs.x)),
701
709
  y: Math.max(0, Math.round(ev.y * sc - node.abs.y)),
702
- rootx: Math.round(ev.x * sc),
703
- rooty: Math.round(ev.y * sc),
710
+ rootx: Math.round(origin.x + ev.x * sc),
711
+ rooty: Math.round(origin.y + ev.y * sc),
704
712
  // The modifier mask, which a synthetic event carries on its native
705
713
  // event alone. Read off the synthetic one, every key and press
706
714
  // reached the pane with none: Shift+Tab went forwards there, and a
@@ -0,0 +1,132 @@
1
+ // A field's selection, read and set from outside it: `selectionStart`,
2
+ // `selectionEnd`, `selectionDirection`, `setSelectionRange()` and
3
+ // `select()`, the DOM's names on the node, for <textinput> and <textarea>
4
+ // alike. What a copy-link box, a search field that selects on focus, an
5
+ // input mask or an emoji picker does through a DOM input's, an application
6
+ // does through the ref — and `@react-x11/components`' `<Html>` does for the
7
+ // page's scripts, on the field it mounts for an `<input>`.
8
+ //
9
+ // Two things here are decisions rather than accidents.
10
+ //
11
+ // **The offsets are UTF-16 code units**, as the DOM's are, where the rest of
12
+ // the field counts code points (textrange.js). Code that reads them slices
13
+ // `value` with them — `value.slice(selectionStart, selectionEnd)` is what is
14
+ // selected, `value.slice(0, start) + emoji + value.slice(end)` is an insert
15
+ // at the caret — and a JavaScript string is indexed in code units, so an
16
+ // offset in code points is one short for every emoji before it. An offset
17
+ // that falls between the two halves of a surrogate pair is taken to the
18
+ // character's start, as `codePointAtOffset` rounds.
19
+ //
20
+ // **A selection set from outside is not the user's.** It is the field's
21
+ // selection in every way an edit can tell — it is drawn while the field has
22
+ // the focus, Backspace deletes it, typing replaces it, Shift+arrow extends
23
+ // it — but it does not take PRIMARY. On X11 PRIMARY is what the user last
24
+ // selected, for a middle-click to paste somewhere else, and a field that a
25
+ // script selects on focus would otherwise replace it every time it is
26
+ // tabbed into, with a timestamp no user event gave (ICCCM 2.1). It does
27
+ // take the visible selection, where it is drawn: one lit range on screen at
28
+ // a time (textselection.js).
29
+
30
+ import { codePointAtOffset, codeUnitOffsets } from '../textrange.js';
31
+ import { takeVisibleSelection } from '../textselection.js';
32
+
33
+ /** The selection API, installed onto `TextInputNode.prototype` by
34
+ * textinput.js. */
35
+ export class TextInputSelection {
36
+ /** The offset, in UTF-16 code units, where the selection starts — the
37
+ * caret's, where nothing is selected. */
38
+ get selectionStart() {
39
+ return this._unitOffset(this._selection()[0]);
40
+ }
41
+
42
+ /** As the DOM's: the end moves with the start where the start passes it. */
43
+ set selectionStart(start) {
44
+ const at = this._clampUnits(start);
45
+ this.setSelectionRange(
46
+ at,
47
+ Math.max(at, this.selectionEnd),
48
+ this.selectionDirection,
49
+ );
50
+ }
51
+
52
+ /** The offset, in UTF-16 code units, where the selection ends. */
53
+ get selectionEnd() {
54
+ return this._unitOffset(this._selection()[1]);
55
+ }
56
+
57
+ /** As the DOM's: the start moves back to the end where the end is put
58
+ * before it. */
59
+ set selectionEnd(end) {
60
+ this.setSelectionRange(
61
+ this.selectionStart,
62
+ this._clampUnits(end),
63
+ this.selectionDirection,
64
+ );
65
+ }
66
+
67
+ /**
68
+ * Which end the caret is at: `'backward'` where it is at the start,
69
+ * `'forward'` where it is at the end, and `'none'` for a bare caret. A
70
+ * direction set through `setSelectionRange` reads back as it was set —
71
+ * `'none'` included, over a range — until the user moves either end.
72
+ */
73
+ get selectionDirection() {
74
+ const set = this._directionSet;
75
+ if (set && set.caret === this._caret && set.anchor === this._anchor) {
76
+ return set.direction;
77
+ }
78
+ if (this._caret < this._anchor) return 'backward';
79
+ if (this._caret > this._anchor) return 'forward';
80
+ return 'none';
81
+ }
82
+
83
+ set selectionDirection(direction) {
84
+ this.setSelectionRange(this.selectionStart, this.selectionEnd, direction);
85
+ }
86
+
87
+ /**
88
+ * Select `[start, end)`, in UTF-16 code units, as a DOM input's
89
+ * `setSelectionRange` does: each end clamped to the value, a start past
90
+ * the end moved back to it, and the caret at the end — or, for
91
+ * `'backward'`, at the start, so Shift+Left goes on from there. Equal
92
+ * offsets put a bare caret. An open composition is left where it is.
93
+ */
94
+ setSelectionRange(start, end, direction = 'none') {
95
+ const to = this._clampUnits(end);
96
+ const from = Math.min(this._clampUnits(start), to);
97
+ const offsets = codeUnitOffsets(this.value);
98
+ const a = codePointAtOffset(offsets, from);
99
+ const b = codePointAtOffset(offsets, to);
100
+ const backward = direction === 'backward';
101
+ this._anchor = backward ? b : a;
102
+ this._caret = backward ? a : b;
103
+ this._directionSet = {
104
+ direction: backward || direction === 'forward' ? direction : 'none',
105
+ caret: this._caret,
106
+ anchor: this._anchor,
107
+ };
108
+ // typing that resumes at the new caret is a new edit, as after a click
109
+ this._breakUndoRun();
110
+ if (a !== b && this._focused) takeVisibleSelection(this);
111
+ this._repaint();
112
+ }
113
+
114
+ /** Select the whole value, as a DOM input's `select()` does. */
115
+ select() {
116
+ this.setSelectionRange(0, this.value.length);
117
+ }
118
+
119
+ /** A code-point index in the value as a UTF-16 offset. */
120
+ _unitOffset(index) {
121
+ const offsets = codeUnitOffsets(this.value);
122
+ return offsets[Math.min(Math.max(0, index), offsets.length - 1)];
123
+ }
124
+
125
+ /** A UTF-16 offset from outside, as a whole number inside the value:
126
+ * NaN is 0, a fraction is cut, and either side of the value is its end. */
127
+ _clampUnits(offset) {
128
+ const n = Math.trunc(Number(offset));
129
+ if (Number.isNaN(n)) return 0;
130
+ return Math.min(Math.max(0, n), this.value.length);
131
+ }
132
+ }
@@ -178,6 +178,12 @@ export class TextAreaNode extends TextInputNode {
178
178
  super._applyHistory(value, caret, anchor);
179
179
  }
180
180
 
181
+ /** Nor does it once the caret is put somewhere from outside. */
182
+ setSelectionRange(start, end, direction) {
183
+ this._goalX = null;
184
+ super.setSelectionRange(start, end, direction);
185
+ }
186
+
181
187
  /** Wrapped, styled layout of the value (or placeholder), cached per
182
188
  * (text, style, width). Used for painting and all caret geometry, so
183
189
  * caret math always agrees with what is on screen. */
@@ -1,7 +1,8 @@
1
1
  // <textinput>: a single-line editable field — value, caret and selection,
2
2
  // the keys, the mouse, the clipboard, the edit menu and painting. Undo/redo
3
- // is in edithistory.js, IME composition in preedit.js and the Mac's editing
4
- // keys in textkeys.js, installed below.
3
+ // is in edithistory.js, IME composition in preedit.js, the Mac's editing
4
+ // keys in textkeys.js and the selection as the DOM reads and sets it in
5
+ // fieldselection.js, installed below.
5
6
 
6
7
  import { localTextStyleChanged } from '../styles.js';
7
8
  import { Yoga } from '../yoga.js';
@@ -30,6 +31,7 @@ import { takeVisibleSelection } from '../textselection.js';
30
31
  import { TextInputHistory } from './edithistory.js';
31
32
  import { openEditMenu, editMenuOpen } from './editmenupopup.js';
32
33
  import { installMethods } from './install.js';
34
+ import { TextInputSelection } from './fieldselection.js';
33
35
  import { Node } from './node.js';
34
36
  import { TextInputPreedit } from './preedit.js';
35
37
  import { rangeBands } from './text.js';
@@ -964,6 +966,10 @@ export class TextInputNode extends Node {
964
966
  defaultFocus() {
965
967
  this._focused = true;
966
968
  this._caretOn = true;
969
+ // A selection the field kept while it had no focus — one set from
970
+ // outside (`setSelectionRange`) — is drawn from now on, and is the one
971
+ // lit range on screen, as one the user drags here is.
972
+ if (this._caret !== this._anchor) takeVisibleSelection(this);
967
973
  // The desktop's cadence, read at focus rather than at import: XSETTINGS
968
974
  // is started but not awaited by createRoot, so this is the first moment
969
975
  // it is reliably in — and a field focused before that gets the default
@@ -1165,10 +1171,12 @@ export class TextInputNode extends Node {
1165
1171
  }
1166
1172
  }
1167
1173
 
1168
- // Undo/redo and IME composition live in their own files (see install.js).
1174
+ // Undo/redo, IME composition, the Mac's keys and the selection API live in
1175
+ // their own files (see install.js).
1169
1176
  installMethods(
1170
1177
  TextInputNode,
1171
1178
  TextInputHistory,
1172
1179
  TextInputPreedit,
1173
1180
  TextInputKeys,
1181
+ TextInputSelection,
1174
1182
  );
@@ -545,6 +545,23 @@ export interface SelectProps<T = unknown>
545
545
  * Ignored where the backend has no such menu.
546
546
  */
547
547
  nativeMenu?: boolean;
548
+ /**
549
+ * Whether the menu is down, held by the owner as `value` holds the
550
+ * option: it drops when this goes `true` and comes up when it goes
551
+ * `false`, and what the user does to open or close it — a press, a key,
552
+ * a pick, a click outside — asks {@link SelectProps.onOpenChange} instead
553
+ * of doing it. Left out, the select keeps its own.
554
+ */
555
+ open?: boolean;
556
+ /**
557
+ * Told each time the menu goes down or comes up, the drawn one or the
558
+ * platform's, and with `open` asked when the user would move it. A menu
559
+ * that cannot drop — its trigger in no window — or that goes by itself,
560
+ * the platform's dismissing itself or the trigger scrolling out of view,
561
+ * is told as `false`. A change the owner made through `open` is not told
562
+ * back.
563
+ */
564
+ onOpenChange?: (open: boolean) => void;
548
565
  }
549
566
  export function Select<T = unknown>(props: SelectProps<T>): ReactNode;
550
567
 
@@ -200,8 +200,43 @@ export interface TextInputNode extends DrawnNode {
200
200
  redo(): boolean;
201
201
  readonly canUndo: boolean;
202
202
  readonly canRedo: boolean;
203
+ /**
204
+ * Where the selection starts, as a DOM input's `selectionStart` says it:
205
+ * an offset into `value` in **UTF-16 code units**, so `value.slice(
206
+ * selectionStart, selectionEnd)` is the selected text. The caret's, where
207
+ * nothing is selected. Setting it moves the end with it where it passes
208
+ * the end.
209
+ */
210
+ selectionStart: number;
211
+ /** Where the selection ends, in UTF-16 code units. Setting it before the
212
+ * start moves the start back to it. */
213
+ selectionEnd: number;
214
+ /**
215
+ * Which end the caret is at: `'backward'` at the start, `'forward'` at the
216
+ * end, `'none'` for a bare caret — or as `setSelectionRange` set it, until
217
+ * the user moves either end.
218
+ */
219
+ selectionDirection: TextSelectionDirection;
220
+ /**
221
+ * Select `[start, end)`, in UTF-16 code units, as a DOM input's
222
+ * `setSelectionRange` does: clamped to the value, a start past the end
223
+ * moved back to it, the caret at the end or, for `'backward'`, at the
224
+ * start. Equal offsets put a bare caret there. Drawn while the field has
225
+ * the focus, and edited as the user's own selection is; it does not take
226
+ * PRIMARY, which is the user's last selection.
227
+ */
228
+ setSelectionRange(
229
+ start: number,
230
+ end: number,
231
+ direction?: TextSelectionDirection,
232
+ ): void;
233
+ /** Select the whole value, as a DOM input's `select()` does. */
234
+ select(): void;
203
235
  }
204
236
 
237
+ /** A field's `selectionDirection`, as the DOM names it. */
238
+ export type TextSelectionDirection = 'forward' | 'backward' | 'none';
239
+
205
240
  /**
206
241
  * ntk's `Window`, as handed back by a `<window>` or `<popup>` ref. This
207
242
  * covers what a react-x11 program usually reaches for; ntk has more (see