react-x11 2.47.3 → 2.47.5
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/package.json +2 -2
- package/src/anchor.js +48 -22
- package/src/cocoa/app.js +13 -105
- package/src/cocoa/clipboard.js +485 -0
- package/src/cocoa/idle.js +92 -0
- package/src/cocoa/statusitem.js +30 -10
- package/src/components/Dialog.js +69 -28
- package/src/components/Menu.js +64 -23
- package/src/events.js +22 -4
- package/src/frame/index.js +4 -1
- package/src/idle.js +50 -20
- package/src/idlehooks.js +5 -2
- package/src/index.d.ts +14 -3
- package/src/nodes/node.js +6 -0
- package/src/nodes/textarea.js +10 -1
- package/src/nodes/textinput.js +8 -0
- package/src/nodes/window/anchoring.js +87 -2
- package/src/nodes/window/floors.js +26 -1
- package/src/nodes/window/spine.js +21 -3
- package/src/nodes/window/window.js +9 -0
- package/src/types/components.d.ts +35 -8
- package/src/types/elements.d.ts +5 -1
- package/src/types/system.d.ts +3 -1
package/src/components/Dialog.js
CHANGED
|
@@ -4,15 +4,13 @@
|
|
|
4
4
|
|
|
5
5
|
import React, { useEffect, useLayoutEffect, useRef, useState } from 'react';
|
|
6
6
|
import { capTrim, labelContent, useTheme } from './theme.js';
|
|
7
|
-
import {
|
|
7
|
+
import { windowRect } from '../anchor.js';
|
|
8
8
|
import { XK_ESCAPE } from './keys.js';
|
|
9
9
|
|
|
10
10
|
const h = React.createElement;
|
|
11
11
|
|
|
12
12
|
const DEFAULT_WIDTH = 360;
|
|
13
13
|
|
|
14
|
-
const DEFAULT_HEIGHT = 170;
|
|
15
|
-
|
|
16
14
|
/**
|
|
17
15
|
* <Dialog open title onClose actions>…</Dialog> — a dialog in a `<popup>`,
|
|
18
16
|
* centred over the owner window.
|
|
@@ -58,13 +56,36 @@ const DEFAULT_HEIGHT = 170;
|
|
|
58
56
|
* belongs to the `states` work; it means nothing without the
|
|
59
57
|
* `WM_TRANSIENT_FOR` this now sets, which is why that had to come first.
|
|
60
58
|
*
|
|
61
|
-
*
|
|
62
|
-
*
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
*
|
|
66
|
-
*
|
|
67
|
-
* window
|
|
59
|
+
* **The height is the content's** (issue #940). It used to be 170 whatever
|
|
60
|
+
* the dialog held, because the dialog was centred from React — and centring
|
|
61
|
+
* needs a size, which React does not have before anything is laid out. So
|
|
62
|
+
* content past 170 was laid out below the bottom of the window and cut off
|
|
63
|
+
* there, `actions` first: the one row a dialog cannot do without was the
|
|
64
|
+
* first to go. Now the popup centres *itself*: `anchor={{ rect, placement:
|
|
65
|
+
* 'center' }}` over its owner window, and `realize()` measures the content,
|
|
66
|
+
* places it from that size and only then creates the window — so it is born
|
|
67
|
+
* the right size in the right place, the order an anchored menu already
|
|
68
|
+
* had. `height="auto"` is the default because a fixed height that clips is
|
|
69
|
+
* never what anyone means; a number is still exactly that number.
|
|
70
|
+
*
|
|
71
|
+
* Three things follow from it:
|
|
72
|
+
*
|
|
73
|
+
* - **Capped at the monitor**, as every `'auto'` window is. Past that the
|
|
74
|
+
* body scrolls (`overflow: 'scroll'`, a tab stop only while it does) and
|
|
75
|
+
* the actions row stays pinned under it, in the window — under a fixed
|
|
76
|
+
* height too, which used to clip the same way.
|
|
77
|
+
* - **It follows its content** while open (the `'auto'` re-fit, #929), and
|
|
78
|
+
* grows from where it is rather than being centred again: its top edge
|
|
79
|
+
* stays put, so a line appearing under a field does not move the field
|
|
80
|
+
* under the pointer. It is pulled up only as far as it takes to stay on
|
|
81
|
+
* the monitor (`_keepCentredTop`, nodes/window/anchoring.js).
|
|
82
|
+
* - **It is centred once**, over where the owner was when it opened — the
|
|
83
|
+
* owner's rect is taken when the dialog opens and is not tracked after,
|
|
84
|
+
* so a managed dialog the user dragged aside stays where they put it.
|
|
85
|
+
*
|
|
86
|
+
* `width` stays a number by default (360): a body of prose wraps to it, and
|
|
87
|
+
* a natural width would be as wide as its longest sentence. `width="auto"`
|
|
88
|
+
* is there for a dialog whose content is laid out to its own width.
|
|
68
89
|
*/
|
|
69
90
|
export function Dialog({
|
|
70
91
|
open,
|
|
@@ -74,35 +95,38 @@ export function Dialog({
|
|
|
74
95
|
actions,
|
|
75
96
|
managed = true,
|
|
76
97
|
width = DEFAULT_WIDTH,
|
|
77
|
-
height =
|
|
98
|
+
height = 'auto',
|
|
78
99
|
style,
|
|
79
100
|
...boxProps
|
|
80
101
|
}) {
|
|
81
102
|
const theme = useTheme();
|
|
82
103
|
const anchor = useRef(null);
|
|
83
104
|
const surface = useRef(null);
|
|
84
|
-
|
|
105
|
+
// the owner window's rect on the screen, which the popup centres itself
|
|
106
|
+
// over — taken once, when the dialog opens (see above)
|
|
107
|
+
const [owner, setOwner] = useState(null);
|
|
85
108
|
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
109
|
+
// Screen coordinates are only knowable once the anchor is laid out inside
|
|
110
|
+
// a realized window — hence a layout effect rather than placement at
|
|
111
|
+
// render time. The size is not needed here any more: the popup works out
|
|
112
|
+
// its own, and its own position from it.
|
|
89
113
|
useLayoutEffect(() => {
|
|
90
114
|
if (!open) {
|
|
91
|
-
|
|
115
|
+
setOwner(null);
|
|
92
116
|
return;
|
|
93
117
|
}
|
|
94
|
-
const next =
|
|
95
|
-
if (next)
|
|
96
|
-
}, [open
|
|
118
|
+
const next = windowRect(anchor.current);
|
|
119
|
+
if (next) setOwner(next);
|
|
120
|
+
}, [open]);
|
|
97
121
|
|
|
98
122
|
// nothing inside claimed focus (no autoFocus): the dialog takes it itself,
|
|
99
123
|
// the way a browser focuses a <dialog> element, so keys have somewhere to
|
|
100
124
|
// land and the trap has an inside
|
|
101
125
|
useEffect(() => {
|
|
102
126
|
const node = surface.current;
|
|
103
|
-
if (!
|
|
127
|
+
if (!owner || !node || node.focusWithin) return;
|
|
104
128
|
node.focus();
|
|
105
|
-
}, [
|
|
129
|
+
}, [owner]);
|
|
106
130
|
|
|
107
131
|
const onKeyDown = (ev) => {
|
|
108
132
|
if (ev.keysym === XK_ESCAPE) {
|
|
@@ -119,14 +143,13 @@ export function Dialog({
|
|
|
119
143
|
ref: anchor,
|
|
120
144
|
style: { position: 'absolute', width: 0, height: 0 },
|
|
121
145
|
},
|
|
122
|
-
|
|
146
|
+
owner &&
|
|
123
147
|
h(
|
|
124
148
|
'popup',
|
|
125
149
|
{
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
height: rect.height,
|
|
150
|
+
anchor: { rect: owner, placement: 'center' },
|
|
151
|
+
width,
|
|
152
|
+
height,
|
|
130
153
|
trapFocus: true,
|
|
131
154
|
// A managed window is the window manager's to move, and a
|
|
132
155
|
// client-side pointer grab over one is a fight nobody wins — the
|
|
@@ -181,10 +204,28 @@ export function Dialog({
|
|
|
181
204
|
},
|
|
182
205
|
title,
|
|
183
206
|
),
|
|
207
|
+
// The body is what gives way when the dialog cannot be as tall as
|
|
208
|
+
// its content — capped at the monitor, or held to a `height` — so
|
|
209
|
+
// it scrolls, and the actions row below it never leaves the window.
|
|
210
|
+
//
|
|
211
|
+
// The column inside it is `height: '100%'` rather than its natural
|
|
212
|
+
// height, which is what keeps the body's old behaviour for content
|
|
213
|
+
// that fills it: inside a scroller a box is as tall as its content,
|
|
214
|
+
// and a dialog built as a field over a list that scrolls on its own
|
|
215
|
+
// (the chat example's) would have its list laid out whole and the
|
|
216
|
+
// body scrolled instead. At the viewport's height that content is
|
|
217
|
+
// squeezed as it always was, and content that cannot be squeezed
|
|
218
|
+
// that far — lines of text — is held at its automatic minimum
|
|
219
|
+
// (#249), which is taller than the viewport, and that is the
|
|
220
|
+
// overflow the body scrolls.
|
|
184
221
|
h(
|
|
185
222
|
'box',
|
|
186
|
-
{ style: { flexGrow: 1,
|
|
187
|
-
|
|
223
|
+
{ style: { flexGrow: 1, overflow: 'scroll' } },
|
|
224
|
+
h(
|
|
225
|
+
'box',
|
|
226
|
+
{ style: { gap: 8, height: '100%' } },
|
|
227
|
+
labelContent(children, { color: theme.text }),
|
|
228
|
+
),
|
|
188
229
|
),
|
|
189
230
|
actions &&
|
|
190
231
|
h(
|
package/src/components/Menu.js
CHANGED
|
@@ -2,7 +2,13 @@
|
|
|
2
2
|
// support needed. Plain createElement (no JSX) so the library stays
|
|
3
3
|
// build-step-free for consumers.
|
|
4
4
|
|
|
5
|
-
import React, {
|
|
5
|
+
import React, {
|
|
6
|
+
useEffect,
|
|
7
|
+
useLayoutEffect,
|
|
8
|
+
useMemo,
|
|
9
|
+
useRef,
|
|
10
|
+
useState,
|
|
11
|
+
} from 'react';
|
|
6
12
|
import {
|
|
7
13
|
capBand,
|
|
8
14
|
capTrim,
|
|
@@ -990,14 +996,45 @@ export function ContextMenu({
|
|
|
990
996
|
...boxProps
|
|
991
997
|
}) {
|
|
992
998
|
const ref = useRef(null);
|
|
993
|
-
|
|
999
|
+
// Where the right-click asked for the menu, in logical root coordinates —
|
|
1000
|
+
// and nothing else. What to show, and so how big a popup and where it can
|
|
1001
|
+
// fit, is decided from the `items` of the render the menu appears in.
|
|
1002
|
+
//
|
|
1003
|
+
// The click cannot decide it: a row inside here says which thing was
|
|
1004
|
+
// right-clicked in its own `onContextMenu`, which runs before ours on the
|
|
1005
|
+
// same event but renders after it, and an app derives the items from that.
|
|
1006
|
+
// Read at the click, the items were the render before's — so a menu with
|
|
1007
|
+
// none before opened on the second right-click, and one that grew showed
|
|
1008
|
+
// its new rows in a popup sized for the old ones (#934).
|
|
1009
|
+
const [anchor, setAnchor] = useState(null);
|
|
994
1010
|
const [path, setPath] = useState([]);
|
|
995
1011
|
const typeAhead = useTypeAhead();
|
|
996
1012
|
const rtl = useDirection() === 'rtl';
|
|
997
1013
|
const metrics = useMenuMetrics(fontSize);
|
|
998
1014
|
|
|
1015
|
+
const showing = anchor != null && visibleItems(items).length > 0;
|
|
1016
|
+
const rect = useMemo(() => {
|
|
1017
|
+
const node = ref.current;
|
|
1018
|
+
if (!showing || !node) return null;
|
|
1019
|
+
const width = menuListWidth(node, items, metrics);
|
|
1020
|
+
const height = menuListHeight(items, metrics);
|
|
1021
|
+
const area = anchorArea(node);
|
|
1022
|
+
// anchored at the pointer rather than at a widget: clamp by hand, since
|
|
1023
|
+
// there is no anchor rect to flip around
|
|
1024
|
+
return {
|
|
1025
|
+
x: area
|
|
1026
|
+
? Math.max(area.x, Math.min(anchor.x, area.x + area.width - width))
|
|
1027
|
+
: anchor.x,
|
|
1028
|
+
y: area
|
|
1029
|
+
? Math.max(area.y, Math.min(anchor.y, area.y + area.height - height))
|
|
1030
|
+
: anchor.y,
|
|
1031
|
+
width,
|
|
1032
|
+
height,
|
|
1033
|
+
};
|
|
1034
|
+
}, [showing, anchor, items, metrics]);
|
|
1035
|
+
|
|
999
1036
|
const close = () => {
|
|
1000
|
-
|
|
1037
|
+
setAnchor(null);
|
|
1001
1038
|
setPath([]);
|
|
1002
1039
|
};
|
|
1003
1040
|
const select = (item) => {
|
|
@@ -1016,30 +1053,34 @@ export function ContextMenu({
|
|
|
1016
1053
|
// pointer grab until something closes it
|
|
1017
1054
|
useDismissOnWindowBlur(ref, Boolean(rect), close);
|
|
1018
1055
|
|
|
1056
|
+
// Settled in the commit the click's own updates land in, before anything
|
|
1057
|
+
// is drawn. Nothing to show is no menu at all — no popup, so no grab, and
|
|
1058
|
+
// the focus left where the press put it — and the request is dropped
|
|
1059
|
+
// rather than kept, or items arriving later would open a menu nobody
|
|
1060
|
+
// asked for just then. The same goes for items emptied under an open menu.
|
|
1061
|
+
useLayoutEffect(() => {
|
|
1062
|
+
if (!anchor) return;
|
|
1063
|
+
if (!showing) {
|
|
1064
|
+
close();
|
|
1065
|
+
return;
|
|
1066
|
+
}
|
|
1067
|
+
// `'pointer'`, so no ring: this only ever opens from a right-click, and
|
|
1068
|
+
// the wrapper is the whole content area — a focus ring around all of it
|
|
1069
|
+
// says nothing a keyboard user needs and quite a lot nobody wants.
|
|
1070
|
+
const node = ref.current;
|
|
1071
|
+
node?.root?.events?.focus?.(node, 'pointer');
|
|
1072
|
+
}, [anchor, showing]);
|
|
1073
|
+
|
|
1019
1074
|
const openAt = (ev) => {
|
|
1020
1075
|
const node = ref.current;
|
|
1021
|
-
if (!node
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
|
|
1027
|
-
// the rect feeds popup props, which are logical like the area.
|
|
1028
|
-
const x = (ev.nativeEvent?.rootx ?? ev.x * node.scale) / node.scale;
|
|
1029
|
-
const y = (ev.nativeEvent?.rooty ?? ev.y * node.scale) / node.scale;
|
|
1030
|
-
setRect({
|
|
1031
|
-
x: area ? Math.max(area.x, Math.min(x, area.x + area.width - width)) : x,
|
|
1032
|
-
y: area
|
|
1033
|
-
? Math.max(area.y, Math.min(y, area.y + area.height - height))
|
|
1034
|
-
: y,
|
|
1035
|
-
width,
|
|
1036
|
-
height,
|
|
1076
|
+
if (!node) return;
|
|
1077
|
+
// Root coordinates are device; the anchor feeds popup props, which are
|
|
1078
|
+
// logical like the area they are clamped to.
|
|
1079
|
+
setAnchor({
|
|
1080
|
+
x: (ev.nativeEvent?.rootx ?? ev.x * node.scale) / node.scale,
|
|
1081
|
+
y: (ev.nativeEvent?.rooty ?? ev.y * node.scale) / node.scale,
|
|
1037
1082
|
});
|
|
1038
1083
|
setPath([-1]);
|
|
1039
|
-
// `'pointer'`, so no ring: this only ever opens from a right-click, and
|
|
1040
|
-
// the wrapper is the whole content area — a focus ring around all of it
|
|
1041
|
-
// says nothing a keyboard user needs and quite a lot nobody wants.
|
|
1042
|
-
node.root?.events?.focus?.(node, 'pointer');
|
|
1043
1084
|
};
|
|
1044
1085
|
|
|
1045
1086
|
return h(
|
package/src/events.js
CHANGED
|
@@ -424,7 +424,10 @@ export class EventManager {
|
|
|
424
424
|
// windows' focus events settle it once rather than twice
|
|
425
425
|
// (`_syncDefaultFocus`)
|
|
426
426
|
this._defaultFocusOn = false;
|
|
427
|
-
|
|
427
|
+
// the last press, which the next is counted against (`_clickDetail`).
|
|
428
|
+
// No time until there has been one: a 0 here made the first press on a
|
|
429
|
+
// server up for less than the double-click window count as a second.
|
|
430
|
+
this._lastClick = { time: null, stamped: false, x: 0, y: 0, detail: 0 };
|
|
428
431
|
// the sub-pixel part of a smooth scroll the default action has not spent
|
|
429
432
|
// yet — see `_onWheel`, which moves whole pixels so the scroll blit stays
|
|
430
433
|
// available
|
|
@@ -567,9 +570,19 @@ export class EventManager {
|
|
|
567
570
|
* and `Net/DoubleClickDistance` — falling back to 400ms/4px where no
|
|
568
571
|
* settings daemon answered. A double click that needs to be faster here
|
|
569
572
|
* than everywhere else on the desktop is a double click people miss.
|
|
573
|
+
*
|
|
574
|
+
* The time is the press's own, which is when the user pressed. When it was
|
|
575
|
+
* handled is another matter: a first click whose render outlasts the
|
|
576
|
+
* window holds the second press back past it, and a double click read off
|
|
577
|
+
* the clock came out as two singles (#936). The X server stamps a press
|
|
578
|
+
* in milliseconds, and so do AppKit (its uptime) and a Wayland
|
|
579
|
+
* compositor. A press that carries no time (win32's, a test's `emit`) is
|
|
580
|
+
* timed by the clock instead, and never against one that carried its own:
|
|
581
|
+
* the two count from nothing in common.
|
|
570
582
|
*/
|
|
571
583
|
_clickDetail(native) {
|
|
572
|
-
const
|
|
584
|
+
const stamped = typeof native.time === 'number';
|
|
585
|
+
const now = stamped ? native.time : Date.now();
|
|
573
586
|
const last = this._lastClick;
|
|
574
587
|
const { doubleClickMs, doubleClickDistance } = desktopSettings(
|
|
575
588
|
this.node?.app,
|
|
@@ -577,12 +590,17 @@ export class EventManager {
|
|
|
577
590
|
// the desktop's slop is logical pixels; the coordinates are device
|
|
578
591
|
const slop = doubleClickDistance * this.scale;
|
|
579
592
|
const detail =
|
|
580
|
-
|
|
593
|
+
last.time !== null &&
|
|
594
|
+
last.stamped === stamped &&
|
|
595
|
+
// X time is 32 bits and wraps every 49.7 days: unsigned, a press just
|
|
596
|
+
// past the wrap is still just after the one before it, and one stamped
|
|
597
|
+
// earlier than the last is never inside the window
|
|
598
|
+
(now - last.time) >>> 0 < doubleClickMs &&
|
|
581
599
|
Math.abs(native.x - last.x) <= slop &&
|
|
582
600
|
Math.abs(native.y - last.y) <= slop
|
|
583
601
|
? last.detail + 1
|
|
584
602
|
: 1;
|
|
585
|
-
this._lastClick = { time: now, x: native.x, y: native.y, detail };
|
|
603
|
+
this._lastClick = { time: now, stamped, x: native.x, y: native.y, detail };
|
|
586
604
|
return detail;
|
|
587
605
|
}
|
|
588
606
|
|
package/src/frame/index.js
CHANGED
|
@@ -706,7 +706,10 @@ function PaneHostView({
|
|
|
706
706
|
// reached the pane with none: Shift+Tab went forwards there, and a
|
|
707
707
|
// Ctrl+C or a Shift+click was a C and a click.
|
|
708
708
|
buttons: ev.nativeEvent?.buttons ?? 0,
|
|
709
|
-
time
|
|
709
|
+
// And the time the input carried, which the pane counts a double
|
|
710
|
+
// click by: stamped as the host got round to sending it, a first click
|
|
711
|
+
// the host was slow to answer put the second outside the window (#936)
|
|
712
|
+
time: ev.nativeEvent?.time ?? Date.now(),
|
|
710
713
|
...data(ev),
|
|
711
714
|
};
|
|
712
715
|
try {
|
package/src/idle.js
CHANGED
|
@@ -31,6 +31,16 @@
|
|
|
31
31
|
// next check is 288 seconds away — so an active user costs one round trip per
|
|
32
32
|
// timeout period.
|
|
33
33
|
//
|
|
34
|
+
// A backend with no X server says the same number itself, as
|
|
35
|
+
// `app.lastInputMs()`: `GetLastInputInfo` on win32, and on macOS the bridge's
|
|
36
|
+
// CoreGraphics read or `ioreg`'s `HIDIdleTime` (src/cocoa/idle.js). That rung
|
|
37
|
+
// polls on the same schedule, and may answer with a promise.
|
|
38
|
+
//
|
|
39
|
+
// Every rung stops when the last subscriber to its timeout goes. A watcher
|
|
40
|
+
// that outlived its component kept its alarm, or its poll, for the life of
|
|
41
|
+
// the root — near free for an alarm, and a child process per check for
|
|
42
|
+
// `ioreg`.
|
|
43
|
+
//
|
|
34
44
|
// ## Inhibition: three rungs that agree on what they do
|
|
35
45
|
//
|
|
36
46
|
// All three keep the **screen** awake and none of them stops a suspend. That
|
|
@@ -187,11 +197,12 @@ class IdleWatcher {
|
|
|
187
197
|
async function armIdle(watcher) {
|
|
188
198
|
const session = watcher.session;
|
|
189
199
|
|
|
190
|
-
// The
|
|
191
|
-
// below this reaches into `app.X`, and
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
200
|
+
// The rung for a backend with no X server, first because it is the only
|
|
201
|
+
// one there: everything below this reaches into `app.X`, and the cocoa and
|
|
202
|
+
// win32 apps carry at most a stub of one. `GetLastInputInfo` and macOS's
|
|
203
|
+
// idle time both answer for the whole session, which is what an idle
|
|
204
|
+
// timeout means, so the shape is the polling rung's — with the answer
|
|
205
|
+
// coming from a call, or from a child process, instead of a connection.
|
|
195
206
|
if (typeof session.app?.lastInputMs === 'function') {
|
|
196
207
|
pollLastInput(watcher);
|
|
197
208
|
return;
|
|
@@ -293,27 +304,37 @@ function poll(watcher, saver) {
|
|
|
293
304
|
}
|
|
294
305
|
|
|
295
306
|
/**
|
|
296
|
-
* The same adaptive wait as {@link poll}, over a
|
|
297
|
-
*
|
|
307
|
+
* The same adaptive wait as {@link poll}, over a number the backend reads
|
|
308
|
+
* itself: `GetLastInputInfo` is a call, not a round trip, and so is the cocoa
|
|
309
|
+
* bridge's; the cocoa `ioreg` fallback is a child process, and answers with a
|
|
310
|
+
* promise.
|
|
298
311
|
*
|
|
299
312
|
* The two directions are still asymmetric for the same reason. Not idle yet:
|
|
300
313
|
* sleep for exactly the remainder. Idle: nothing says when the user will come
|
|
301
314
|
* back, and no input reaches this process while they are typing in another
|
|
302
|
-
* window, so poll on an interval scaled to the timeout.
|
|
315
|
+
* window, so poll on an interval scaled to the timeout. No answer at all —
|
|
316
|
+
* `ioreg` failed — asks again a timeout later, the way {@link poll} does on
|
|
317
|
+
* an error, rather than settling on "not idle" for good.
|
|
303
318
|
*/
|
|
304
|
-
function pollLastInput(watcher) {
|
|
319
|
+
async function pollLastInput(watcher) {
|
|
305
320
|
if (watcher.stopped) return;
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
321
|
+
let elapsed = null;
|
|
322
|
+
try {
|
|
323
|
+
elapsed = await watcher.session.app.lastInputMs();
|
|
324
|
+
} catch {
|
|
325
|
+
// no answer this time; asked again below
|
|
326
|
+
}
|
|
327
|
+
if (watcher.stopped) return;
|
|
328
|
+
let wait = watcher.timeout;
|
|
329
|
+
if (Number.isFinite(elapsed)) {
|
|
330
|
+
const idle = elapsed >= watcher.timeout;
|
|
331
|
+
watcher.set(idle);
|
|
332
|
+
wait = idle
|
|
314
333
|
? Math.min(30_000, Math.max(1_000, watcher.timeout / 4))
|
|
315
|
-
: Math.max(250, watcher.timeout - elapsed)
|
|
316
|
-
|
|
334
|
+
: Math.max(250, watcher.timeout - elapsed);
|
|
335
|
+
}
|
|
336
|
+
clearTimeout(watcher._timer);
|
|
337
|
+
watcher._timer = setTimeout(() => pollLastInput(watcher), wait);
|
|
317
338
|
watcher._timer.unref?.();
|
|
318
339
|
}
|
|
319
340
|
|
|
@@ -347,7 +368,16 @@ export function watchIdle(app, timeout, onChange) {
|
|
|
347
368
|
});
|
|
348
369
|
}
|
|
349
370
|
watcher.listeners.add(onChange);
|
|
350
|
-
return () =>
|
|
371
|
+
return () => {
|
|
372
|
+
watcher.listeners.delete(onChange);
|
|
373
|
+
if (watcher.listeners.size > 0 || watcher.stopped) return;
|
|
374
|
+
// The last one out stops the alarm or the poll; the next subscriber to
|
|
375
|
+
// this timeout arms a new watcher.
|
|
376
|
+
watcher.stop();
|
|
377
|
+
if (session.watchers.get(timeout) === watcher) {
|
|
378
|
+
session.watchers.delete(timeout);
|
|
379
|
+
}
|
|
380
|
+
};
|
|
351
381
|
}
|
|
352
382
|
|
|
353
383
|
/** Tear down with the root that started it. */
|
package/src/idlehooks.js
CHANGED
|
@@ -30,8 +30,11 @@ import { idleSnapshot, keepAwake, watchIdle } from './idle.js';
|
|
|
30
30
|
* **no timer at all**: a SYNC alarm fires when the counter crosses `timeout`
|
|
31
31
|
* and again when input pulls it back down. On a server without one (XQuartz)
|
|
32
32
|
* it falls back to polling MIT-SCREEN-SAVER, scheduled against the remaining
|
|
33
|
-
* time rather than on a tick.
|
|
34
|
-
*
|
|
33
|
+
* time rather than on a tick. win32 and macOS answer the same number
|
|
34
|
+
* themselves (`GetLastInputInfo`; the bridge's CoreGraphics read, or `ioreg`
|
|
35
|
+
* under a bridge without one) and are polled on that schedule. On an X
|
|
36
|
+
* display with neither extension it stays `false`, which is the honest
|
|
37
|
+
* answer for a display that cannot be asked.
|
|
35
38
|
*
|
|
36
39
|
* `timeout` is a dependency: passing a computed value re-arms on every change,
|
|
37
40
|
* so hold it in a constant or a `useMemo` rather than building it inline from
|
package/src/index.d.ts
CHANGED
|
@@ -135,11 +135,16 @@ export interface ClipboardOptions {
|
|
|
135
135
|
export interface SelectionChange {
|
|
136
136
|
selection: string;
|
|
137
137
|
/** Window now owning it, or `0` when nothing does — the case an edit
|
|
138
|
-
* menu wants.
|
|
138
|
+
* menu wants. On the Cocoa backend no window owns the pasteboard: `-1`
|
|
139
|
+
* when it holds anything, `0` when it was left empty. */
|
|
139
140
|
owner: number;
|
|
140
141
|
timestamp?: number;
|
|
141
142
|
selectionTimestamp?: number;
|
|
142
143
|
reason?: 'new-owner' | 'destroyed' | 'closed';
|
|
144
|
+
/** Cocoa only: `NSPasteboard.changeCount` after the change, which every
|
|
145
|
+
* write bumps — what a clipboard history tells one copy from the next
|
|
146
|
+
* by. */
|
|
147
|
+
changeCount?: number;
|
|
143
148
|
}
|
|
144
149
|
|
|
145
150
|
/** What `useClipboard()` returns. See docs/clipboard.md. */
|
|
@@ -152,7 +157,10 @@ export interface Clipboard {
|
|
|
152
157
|
writeText(text: string, options?: ClipboardOptions): Promise<void>;
|
|
153
158
|
/** Stop owning the selection, so nothing is served for it any more. */
|
|
154
159
|
clear(selection?: string): Promise<void>;
|
|
155
|
-
/** What the current owner can convert to; `[]` when nothing owns it.
|
|
160
|
+
/** What the current owner can convert to; `[]` when nothing owns it.
|
|
161
|
+
* On the Cocoa backend, the pasteboard's types: MIME names where there is
|
|
162
|
+
* one (`image/png`), text as both its X and MIME names, and any other
|
|
163
|
+
* type under its own identifier (`org.nspasteboard.ConcealedType`). */
|
|
156
164
|
targets(options?: ClipboardOptions): Promise<string[]>;
|
|
157
165
|
/** Plain text, via the owner's `UTF8_STRING`/`STRING` targets. Rejects
|
|
158
166
|
* when there is no owner or it offers neither — `read('text')` is the
|
|
@@ -169,7 +177,10 @@ export interface Clipboard {
|
|
|
169
177
|
options?: ClipboardOptions,
|
|
170
178
|
): Promise<Array<{ uri: string; path?: string }>>;
|
|
171
179
|
/** Called whenever the selection changes hands. Resolves to an
|
|
172
|
-
* unsubscribe function. Rejects on a server without XFixes.
|
|
180
|
+
* unsubscribe function. Rejects on a server without XFixes. On the Cocoa
|
|
181
|
+
* backend it is the pasteboard's change count, asked four times a second
|
|
182
|
+
* while something is watching: copies closer together than that are one
|
|
183
|
+
* change. */
|
|
173
184
|
watch(handler: (change: SelectionChange) => void): Promise<() => void>;
|
|
174
185
|
watch(
|
|
175
186
|
selection: string,
|
package/src/nodes/node.js
CHANGED
|
@@ -1033,6 +1033,12 @@ export class Node {
|
|
|
1033
1033
|
// point. A key an open composition took reaches no default action at all.
|
|
1034
1034
|
const space = ev.keysym === XK_SPACE || ev.codepoint === 32;
|
|
1035
1035
|
if (!enter && !space) return;
|
|
1036
|
+
// …unmodified, or with Shift alone. Ctrl, Alt or Super make the key a
|
|
1037
|
+
// chord, which is a command and somebody else's — a menu's Ctrl+Return,
|
|
1038
|
+
// ⌘↩ on the Cocoa backend — and a press here would consume it on the way
|
|
1039
|
+
// past (#935). Shift is not a command: it reaches `onClick` as a
|
|
1040
|
+
// shift-click, the way GTK's `activate` takes it too.
|
|
1041
|
+
if (ev.ctrlKey || ev.altKey || ev.metaKey) return;
|
|
1036
1042
|
if (!hasClickHandler(this)) return;
|
|
1037
1043
|
// consumed, said the way every default action says it: what it prevents
|
|
1038
1044
|
// is the default action after this one
|
package/src/nodes/textarea.js
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
XK_PAGE_UP,
|
|
11
11
|
XK_PAGE_DOWN,
|
|
12
12
|
XK_END,
|
|
13
|
+
primaryModifier,
|
|
13
14
|
primaryModifierHeld,
|
|
14
15
|
} from '../keysyms.js';
|
|
15
16
|
import {
|
|
@@ -310,11 +311,19 @@ export class TextAreaNode extends TextInputNode {
|
|
|
310
311
|
const layout = this._valueLayout();
|
|
311
312
|
|
|
312
313
|
if (k === XK_RETURN || k === XK_KP_ENTER) {
|
|
313
|
-
// Ctrl+Enter, ⌘↩ on the Cocoa backend
|
|
314
|
+
// Ctrl+Enter, ⌘↩ on the Cocoa backend — the field's submit when it has
|
|
315
|
+
// an `onSubmit`, and with none a chord it has no answer for, which goes
|
|
316
|
+
// on to a shortcut the way it does from a <textinput> (#935)
|
|
314
317
|
if (primaryModifierHeld(ev, this.app)) {
|
|
318
|
+
if (!this.props.onSubmit) return false;
|
|
315
319
|
this._fireValueEvent('onSubmit', this.value, ev.nativeEvent);
|
|
316
320
|
return true;
|
|
317
321
|
}
|
|
322
|
+
// Alt+Return and Super+Return are chords too, and a field types
|
|
323
|
+
// nothing for a chord — except on the Mac, where ⌃↩ and ⌥↩ are
|
|
324
|
+
// AppKit's own newline bindings and its shortcuts are ⌘ chords anyway
|
|
325
|
+
const mac = primaryModifier(this.app) === 'Super';
|
|
326
|
+
if (!mac && (ev.altKey || ev.metaKey)) return false;
|
|
318
327
|
this._goalX = null;
|
|
319
328
|
this._insert('\n');
|
|
320
329
|
return true;
|
package/src/nodes/textinput.js
CHANGED
|
@@ -506,6 +506,14 @@ export class TextInputNode extends Node {
|
|
|
506
506
|
const k = ev.keysym;
|
|
507
507
|
|
|
508
508
|
if (k === XK_RETURN || k === XK_KP_ENTER) {
|
|
509
|
+
// Return submits, and Shift+Return with it — Shift is not a command.
|
|
510
|
+
// Ctrl, Alt or Super make it a chord, and a chord this field has no
|
|
511
|
+
// answer for is not the field's (below): a menu's Ctrl+Return, ⌘↩ on
|
|
512
|
+
// the Cocoa backend, reaches the menu (#935). Nor is a Return nobody
|
|
513
|
+
// listens for — with no `onSubmit` there is no answer to give, and the
|
|
514
|
+
// key goes on to a shortcut that wants it, a form's default button.
|
|
515
|
+
if (ev.ctrlKey || ev.altKey || ev.metaKey) return false;
|
|
516
|
+
if (!this.props.onSubmit) return false;
|
|
509
517
|
this._fireValueEvent('onSubmit', this.value, ev.nativeEvent);
|
|
510
518
|
return true;
|
|
511
519
|
}
|