react-x11 2.54.0 → 2.55.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/components.md +18 -0
- package/docs/elements.md +58 -7
- package/package.json +1 -1
- package/src/cocoa/app.js +32 -0
- package/src/cocoa/glarea.js +220 -18
- package/src/cocoa/overlay.js +16 -1
- package/src/components/Select.js +70 -15
- package/src/events.js +8 -0
- package/src/frame/index.js +10 -2
- package/src/gloverlay.js +57 -25
- package/src/nodes/fieldselection.js +132 -0
- package/src/nodes/paint.js +4 -3
- package/src/nodes/textarea.js +6 -0
- package/src/nodes/textinput.js +11 -3
- package/src/types/components.d.ts +17 -0
- package/src/types/nodes.d.ts +35 -0
package/docs/components.md
CHANGED
|
@@ -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
|
|
|
@@ -1899,9 +1948,10 @@ frame.
|
|
|
1899
1948
|
</glarea>
|
|
1900
1949
|
```
|
|
1901
1950
|
|
|
1902
|
-
- **Cocoa**: one transparent layer
|
|
1951
|
+
- **Cocoa**: one transparent layer per child, as big as the child,
|
|
1952
|
+
stacked above the surface in the children's order and composited by
|
|
1903
1953
|
Core Animation — a translucent fill, antialiased edge or shadow blends
|
|
1904
|
-
with the GL frame.
|
|
1954
|
+
with the GL frame and with the children under it.
|
|
1905
1955
|
- **X11**: one child window per child, stacked above the surface in the
|
|
1906
1956
|
children's order and cut (SHAPE) to where the child painted half its
|
|
1907
1957
|
alpha or more; the GL frame shows everywhere else. A translucent pixel
|
|
@@ -1910,10 +1960,11 @@ frame.
|
|
|
1910
1960
|
(`react-x11/test`'s server) a pane is the child's whole rectangle,
|
|
1911
1961
|
`clearColor` where unpainted.
|
|
1912
1962
|
|
|
1913
|
-
A child that only moves (its `left` or `top`)
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1963
|
+
A child that only moves (its `left` or `top`) is not repainted — on Cocoa
|
|
1964
|
+
its layer moves, and on X11 its pixels are copied and only uncovered strips
|
|
1965
|
+
are drawn; panes it passes over are untouched either way — so content
|
|
1966
|
+
panned over a scene is cheap; scroll panes among the children scroll the
|
|
1967
|
+
same way.
|
|
1917
1968
|
[`REACT_X11_NO_SCROLL_BLIT=1`](debugging.md#react_x11_no_scroll_blit1)
|
|
1918
1969
|
turns the copies off.
|
|
1919
1970
|
|
package/package.json
CHANGED
package/src/cocoa/app.js
CHANGED
|
@@ -508,6 +508,17 @@ export class CocoaApp {
|
|
|
508
508
|
return new CocoaOverlayPane(this, attributes);
|
|
509
509
|
}
|
|
510
510
|
|
|
511
|
+
/**
|
|
512
|
+
* That a `<glarea>`'s children each take a pane of their own here, as on
|
|
513
|
+
* X11, rather than one pane over the whole surface: Core Animation stacks
|
|
514
|
+
* the layers (`CocoaOverlayPane.stackAt`), and each is only as big as its
|
|
515
|
+
* child, so a minimap's viewport moving pushes the minimap's pixels and
|
|
516
|
+
* not the surface's (src/gloverlay.js).
|
|
517
|
+
*/
|
|
518
|
+
get overlaysPerChild() {
|
|
519
|
+
return true;
|
|
520
|
+
}
|
|
521
|
+
|
|
511
522
|
/**
|
|
512
523
|
* The Frame host seam (src/frame/index.js): a pane's composited region
|
|
513
524
|
* in this window. Its presence is what routes <Frame> to the shared-
|
|
@@ -1704,11 +1715,32 @@ export class CocoaApp {
|
|
|
1704
1715
|
* again once the WindowServer lets go of it too
|
|
1705
1716
|
* (`CocoaWindow._surfaceReleased`); the tick at the end of this batch
|
|
1706
1717
|
* runs the frame it was holding.
|
|
1718
|
+
*
|
|
1719
|
+
* A buffer that is no window's is a `<glarea>`'s, which waits on it the
|
|
1720
|
+
* same way (`watchSurfaceRelease`).
|
|
1707
1721
|
*/
|
|
1708
1722
|
_routeSurfaceReleased(ev) {
|
|
1709
1723
|
for (const wnd of this._windows.values()) {
|
|
1710
1724
|
if (wnd._surfaceReleased(ev.id)) return;
|
|
1711
1725
|
}
|
|
1726
|
+
const watcher = this._surfaceWatchers?.get(ev.id);
|
|
1727
|
+
if (!watcher) return;
|
|
1728
|
+
this._surfaceWatchers.delete(ev.id);
|
|
1729
|
+
watcher();
|
|
1730
|
+
}
|
|
1731
|
+
|
|
1732
|
+
/**
|
|
1733
|
+
* Call `fn` once `surface-released` names IOSurface `id`: a buffer a
|
|
1734
|
+
* worker's flip took off a layer that no window owns. The returned
|
|
1735
|
+
* function stops watching.
|
|
1736
|
+
*/
|
|
1737
|
+
watchSurfaceRelease(id, fn) {
|
|
1738
|
+
(this._surfaceWatchers ??= new Map()).set(id, fn);
|
|
1739
|
+
return () => {
|
|
1740
|
+
if (this._surfaceWatchers?.get(id) === fn) {
|
|
1741
|
+
this._surfaceWatchers.delete(id);
|
|
1742
|
+
}
|
|
1743
|
+
};
|
|
1712
1744
|
}
|
|
1713
1745
|
|
|
1714
1746
|
_routeAccessibility(ev) {
|
package/src/cocoa/glarea.js
CHANGED
|
@@ -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
|
|
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 =
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
back
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
|
380
|
-
|
|
381
|
-
|
|
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
|
|
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
|
-
|
|
397
|
-
|
|
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
|
|
package/src/cocoa/overlay.js
CHANGED
|
@@ -19,6 +19,10 @@ import { BackendContext2D } from '../backend/context2d.js';
|
|
|
19
19
|
import { withoutActions } from './quiet.js';
|
|
20
20
|
|
|
21
21
|
export const OVERLAY_Z = 1e7 + 1;
|
|
22
|
+
/** How far above the last each pane of a surface is stacked (`stackAt`): a
|
|
23
|
+
* thousand of them stay under the next whole number, so above every GL
|
|
24
|
+
* layer and every surface's panes above every GL frame, as before. */
|
|
25
|
+
const OVERLAY_STEP = 1 / 1024;
|
|
22
26
|
|
|
23
27
|
export class CocoaOverlayPane {
|
|
24
28
|
constructor(app, options) {
|
|
@@ -43,6 +47,8 @@ export class CocoaOverlayPane {
|
|
|
43
47
|
// show, only whatever the render server makes of that.
|
|
44
48
|
this._presented = false;
|
|
45
49
|
this._hidden = false;
|
|
50
|
+
// where it is among its surface's panes, a child each (`stackAt`)
|
|
51
|
+
this._z = OVERLAY_Z;
|
|
46
52
|
this.setState({
|
|
47
53
|
x: options.x ?? 0,
|
|
48
54
|
y: options.y ?? 0,
|
|
@@ -66,11 +72,20 @@ export class CocoaOverlayPane {
|
|
|
66
72
|
const s = this.scale;
|
|
67
73
|
this._setLayerProps({
|
|
68
74
|
frame: [rect.x / s, rect.y / s, rect.width / s, rect.height / s],
|
|
69
|
-
zPosition:
|
|
75
|
+
zPosition: this._z,
|
|
70
76
|
hidden: this._hidden || !this._presented,
|
|
71
77
|
});
|
|
72
78
|
}
|
|
73
79
|
|
|
80
|
+
/** Stacked `index` panes up from the bottom of its surface's — one a
|
|
81
|
+
* child, in the children's order (src/gloverlay.js, `restack`). */
|
|
82
|
+
stackAt(index) {
|
|
83
|
+
const z = OVERLAY_Z + Math.min(index, 1023) * OVERLAY_STEP;
|
|
84
|
+
if (z === this._z) return;
|
|
85
|
+
this._z = z;
|
|
86
|
+
this._setLayerProps({ zPosition: z });
|
|
87
|
+
}
|
|
88
|
+
|
|
74
89
|
map() {
|
|
75
90
|
this._hidden = false;
|
|
76
91
|
if (this._presented) this._setLayerProps({ hidden: false });
|
package/src/components/Select.js
CHANGED
|
@@ -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 (
|
|
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
|
-
|
|
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,
|
|
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,
|
|
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
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
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:
|
|
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
|
|
package/src/frame/index.js
CHANGED
|
@@ -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
|
package/src/gloverlay.js
CHANGED
|
@@ -17,10 +17,18 @@
|
|
|
17
17
|
// translucent child over a GL surface?
|
|
18
18
|
//
|
|
19
19
|
// - **The Cocoa backend: yes.** Core Animation composites every layer, so
|
|
20
|
-
//
|
|
21
|
-
//
|
|
22
|
-
//
|
|
23
|
-
//
|
|
20
|
+
// a pane is a transparent bitmap layer above the GL layer, and a
|
|
21
|
+
// translucent background, an antialiased edge or a shadow blends with the
|
|
22
|
+
// GL frame under it. `app.createOverlayPane` is how a backend says it
|
|
23
|
+
// composites. Where it can stack its panes as well
|
|
24
|
+
// (`app.overlaysPerChild`), a pane is one per child, as on X11, holding
|
|
25
|
+
// that child alone — the layers under it show through — and as big as
|
|
26
|
+
// what the child reaches: a layer's contents are a copy of its bitmap,
|
|
27
|
+
// colour-matched to the display as it is pushed, and one the size of the
|
|
28
|
+
// surface pushed again at every step a minimap's viewport moved was a
|
|
29
|
+
// quarter of a core on a Mac. A child that only moved moves its layer,
|
|
30
|
+
// and nothing is painted. Otherwise one pane holds every child, over the
|
|
31
|
+
// whole surface.
|
|
24
32
|
// - **X11: no.** A child window is opaque without a compositor, and a
|
|
25
33
|
// compositor would not change that: it redirects top-level windows, not
|
|
26
34
|
// the children inside one. So a pane is a plain child window just big
|
|
@@ -746,8 +754,9 @@ function askShape(app) {
|
|
|
746
754
|
*/
|
|
747
755
|
class Pane {
|
|
748
756
|
/**
|
|
749
|
-
* @param node the child
|
|
750
|
-
*
|
|
757
|
+
* @param node the child a pane is kept by and holds — up to it on X11,
|
|
758
|
+
* alone where it is composited — and null for a composited pane over
|
|
759
|
+
* the whole surface, which holds every child
|
|
751
760
|
* @param overlay the panes it is one of, which `canBlit` asks about
|
|
752
761
|
*/
|
|
753
762
|
constructor(wnd, rect, transparent, node = null, overlay = null) {
|
|
@@ -842,7 +851,8 @@ class Pane {
|
|
|
842
851
|
};
|
|
843
852
|
if (!this.wnd.scrollRegion(local, dx, dy)) return false;
|
|
844
853
|
this.blitted = true;
|
|
845
|
-
|
|
854
|
+
// composited, a pane over it holds none of it
|
|
855
|
+
const panes = this.node && !this.transparent ? this.overlay?.panes : null;
|
|
846
856
|
if (panes) {
|
|
847
857
|
for (let i = panes.indexOf(this) + 1; i < panes.length; i++) {
|
|
848
858
|
panes[i].owe(rect);
|
|
@@ -867,8 +877,11 @@ export class GlOverlay {
|
|
|
867
877
|
constructor(area) {
|
|
868
878
|
this.area = area;
|
|
869
879
|
this.app = area.app;
|
|
870
|
-
//
|
|
880
|
+
// panes the backend composites, transparent, rather than child windows
|
|
871
881
|
this.composited = typeof this.app?.createOverlayPane === 'function';
|
|
882
|
+
// a pane a child: X11's, and a compositor's that stacks its panes; one
|
|
883
|
+
// pane over the whole surface elsewhere
|
|
884
|
+
this.perChild = !this.composited || this.app?.overlaysPerChild === true;
|
|
872
885
|
// bottom to top: the children's order on X11, one pane each
|
|
873
886
|
this.panes = [];
|
|
874
887
|
// X11: each child's pane, kept by the child for as long as it has one
|
|
@@ -908,7 +921,7 @@ export class GlOverlay {
|
|
|
908
921
|
const visible = shown(area) && abs.width > 0 && abs.height > 0;
|
|
909
922
|
// the surface's own rect, rounded the way its window's is
|
|
910
923
|
const surface = visible ? area._geometry() : null;
|
|
911
|
-
if (
|
|
924
|
+
if (this.perChild) return this._syncChildren(surface);
|
|
912
925
|
const rects = surface && area.paintOrder().length ? [surface] : [];
|
|
913
926
|
let changed = false;
|
|
914
927
|
while (this.panes.length > rects.length) {
|
|
@@ -929,13 +942,14 @@ export class GlOverlay {
|
|
|
929
942
|
}
|
|
930
943
|
|
|
931
944
|
/**
|
|
932
|
-
* `sync` on X11
|
|
933
|
-
*
|
|
934
|
-
*
|
|
935
|
-
*
|
|
936
|
-
*
|
|
937
|
-
*
|
|
938
|
-
* is under its
|
|
945
|
+
* `sync` on X11, and where panes composite a child each: a pane per
|
|
946
|
+
* child, kept by the child. A child that is new gets one, a child that
|
|
947
|
+
* went takes its with it, and one whose reach moved has its pane placed
|
|
948
|
+
* there — and painted whole, unless the move was the child's own and
|
|
949
|
+
* nothing else, which the window's move carries (`_settleMove` repaints
|
|
950
|
+
* what it does not). A change of order restacks them, and on X11 repaints
|
|
951
|
+
* whichever overlap another: what an X11 pane holds is what is under its
|
|
952
|
+
* child.
|
|
939
953
|
*/
|
|
940
954
|
_syncChildren(surface) {
|
|
941
955
|
const regions = surface ? childRegions(this.area, surface) : [];
|
|
@@ -981,9 +995,10 @@ export class GlOverlay {
|
|
|
981
995
|
this.panes = next;
|
|
982
996
|
if (reordered) {
|
|
983
997
|
this.restack();
|
|
984
|
-
if (!made) {
|
|
998
|
+
if (!made && !this.composited) {
|
|
985
999
|
// what each pane holds is its child and what is under it: a new
|
|
986
|
-
// order is new contents wherever two of them meet
|
|
1000
|
+
// order is new contents wherever two of them meet — on X11, where a
|
|
1001
|
+
// pane is opaque; a composited one holds its child alone
|
|
987
1002
|
for (const pane of next) {
|
|
988
1003
|
if (
|
|
989
1004
|
next.some(
|
|
@@ -1041,7 +1056,11 @@ export class GlOverlay {
|
|
|
1041
1056
|
* Cocoa backend the layer's zPosition is the whole of it.
|
|
1042
1057
|
*/
|
|
1043
1058
|
restack() {
|
|
1044
|
-
if (this.composited)
|
|
1059
|
+
if (this.composited) {
|
|
1060
|
+
// a composited pane a child is stacked by the backend, in order
|
|
1061
|
+
this.panes.forEach((pane, i) => pane.wnd.stackAt?.(i));
|
|
1062
|
+
return;
|
|
1063
|
+
}
|
|
1045
1064
|
const X = this.app?.X;
|
|
1046
1065
|
if (typeof X?.ConfigureWindow !== 'function') return;
|
|
1047
1066
|
let below = this.area.window?.id ?? null;
|
|
@@ -1085,7 +1104,7 @@ export class GlOverlay {
|
|
|
1085
1104
|
if (!root || root.destroyed || root._paneDamage === FULL_DAMAGE) {
|
|
1086
1105
|
return true;
|
|
1087
1106
|
}
|
|
1088
|
-
if (
|
|
1107
|
+
if (this.perChild) {
|
|
1089
1108
|
// Each child's move is its own pane's to settle: carried by the
|
|
1090
1109
|
// window's move where its pane went with it, moved on the pane where
|
|
1091
1110
|
// the pane stayed — cut to the surface, say, which a box bigger than
|
|
@@ -1135,6 +1154,9 @@ export class GlOverlay {
|
|
|
1135
1154
|
* - The panes under it hold nothing of it, and are owed nothing.
|
|
1136
1155
|
*/
|
|
1137
1156
|
_settleCarried({ node, reach, dx, dy }, reaches) {
|
|
1157
|
+
// a composited pane holds its child alone, and the layer's move carried
|
|
1158
|
+
// all of it: nothing is owed anywhere
|
|
1159
|
+
if (this.composited) return;
|
|
1138
1160
|
const panes = this.panes;
|
|
1139
1161
|
const at = panes.indexOf(this.byNode.get(node));
|
|
1140
1162
|
const own = panes[at];
|
|
@@ -1231,10 +1253,15 @@ export class GlOverlay {
|
|
|
1231
1253
|
for (const piece of outside(was, dest)) add(piece);
|
|
1232
1254
|
if (now) for (const piece of outside(now, dest)) add(piece);
|
|
1233
1255
|
// the children whose pixels the pane holds: every other one on a pane
|
|
1234
|
-
// over the whole surface,
|
|
1235
|
-
//
|
|
1256
|
+
// over the whole surface, on X11 the ones under this child — the panes
|
|
1257
|
+
// over it are owed the copy by `Pane.blit` — and none on a composited
|
|
1258
|
+
// pane a child
|
|
1236
1259
|
const order = area.paintOrder();
|
|
1237
|
-
const upTo = own
|
|
1260
|
+
const upTo = !own
|
|
1261
|
+
? order.length
|
|
1262
|
+
: this.composited
|
|
1263
|
+
? 0
|
|
1264
|
+
: order.indexOf(node);
|
|
1238
1265
|
for (let i = 0; i < upTo; i++) {
|
|
1239
1266
|
const other = order[i];
|
|
1240
1267
|
if (other === node) continue;
|
|
@@ -1324,8 +1351,13 @@ export class GlOverlay {
|
|
|
1324
1351
|
}
|
|
1325
1352
|
root._paintDamage = pass;
|
|
1326
1353
|
// an X11 pane holds its child and what is under it, and never
|
|
1327
|
-
// what is over it — the pane over it holds that (see the header)
|
|
1328
|
-
|
|
1354
|
+
// what is over it — the pane over it holds that (see the header);
|
|
1355
|
+
// a composited pane a child holds its child alone
|
|
1356
|
+
area._paintChildren(
|
|
1357
|
+
ctx,
|
|
1358
|
+
pane.node,
|
|
1359
|
+
pane.transparent && pane.node !== null,
|
|
1360
|
+
);
|
|
1329
1361
|
} finally {
|
|
1330
1362
|
root._paintDamage = null;
|
|
1331
1363
|
ctx.restore();
|
|
@@ -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
|
+
}
|
package/src/nodes/paint.js
CHANGED
|
@@ -337,9 +337,10 @@ export class NodePaint {
|
|
|
337
337
|
/**
|
|
338
338
|
* The children, in paint order — or those up to and including `upTo`: an
|
|
339
339
|
* X11 pane over a `<glarea>` holds its own child and what is under it,
|
|
340
|
-
* never what is over it (src/gloverlay.js, `_paintPane`)
|
|
340
|
+
* never what is over it (src/gloverlay.js, `_paintPane`) — or `upTo` alone
|
|
341
|
+
* (`only`), which a composited pane a child holds.
|
|
341
342
|
*/
|
|
342
|
-
_paintChildren(ctx, upTo = null) {
|
|
343
|
+
_paintChildren(ctx, upTo = null, only = false) {
|
|
343
344
|
// A retained presenter replays a node's `paint` into a visual of that
|
|
344
345
|
// node's own — its children have visuals of their own, so it sets this
|
|
345
346
|
// for the duration of the call (src/cocoa/presenter.js, `paintSelf`).
|
|
@@ -348,7 +349,7 @@ export class NodePaint {
|
|
|
348
349
|
let order = this.paintOrder();
|
|
349
350
|
if (upTo !== null) {
|
|
350
351
|
const at = order.indexOf(upTo);
|
|
351
|
-
if (at !== -1) order = order.slice(0, at + 1);
|
|
352
|
+
if (at !== -1) order = only ? [upTo] : order.slice(0, at + 1);
|
|
352
353
|
}
|
|
353
354
|
if (order.length === 0) return;
|
|
354
355
|
if (!this.clipsChildren()) {
|
package/src/nodes/textarea.js
CHANGED
|
@@ -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. */
|
package/src/nodes/textinput.js
CHANGED
|
@@ -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
|
|
4
|
-
// keys in textkeys.js
|
|
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
|
|
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
|
|
package/src/types/nodes.d.ts
CHANGED
|
@@ -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
|