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.
@@ -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 { centerRect } from './anchor.js';
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
- * `width`/`height` are explicit rather than sized to content, and now only
62
- * because of where the dialog is *placed*: `centeredRect` needs the size to
63
- * work out the offset that centres it over its owner, and that is computed
64
- * during the render that opens the popup, before anything has been laid out.
65
- * The popup itself could size to its content — see "Natural size" in
66
- * docs/elements.md — so this is the anchor math's constraint, not the X
67
- * window's.
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 = DEFAULT_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
- const [rect, setRect] = useState(null);
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
- // the popup needs screen coordinates, which are only knowable once the
87
- // anchor is laid out inside a realized window — hence a layout effect
88
- // rather than placement at render time
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
- setRect(null);
115
+ setOwner(null);
92
116
  return;
93
117
  }
94
- const next = centerRect(anchor.current, { width, height });
95
- if (next) setRect(next);
96
- }, [open, width, height]);
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 (!rect || !node || node.focusWithin) return;
127
+ if (!owner || !node || node.focusWithin) return;
104
128
  node.focus();
105
- }, [rect]);
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
- rect &&
146
+ owner &&
123
147
  h(
124
148
  'popup',
125
149
  {
126
- x: rect.x,
127
- y: rect.y,
128
- width: rect.width,
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, gap: 8 } },
187
- labelContent(children, { color: theme.text }),
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(
@@ -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, { useEffect, useMemo, useRef, useState } from '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
- const [rect, setRect] = useState(null);
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
- setRect(null);
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 || !items.length) return;
1022
- const width = menuListWidth(node, items, metrics);
1023
- const height = menuListHeight(items, metrics);
1024
- const area = anchorArea(node);
1025
- // anchored at the pointer rather than at a widget: clamp by hand, since
1026
- // there is no anchor rect to flip around. Root coordinates are device;
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
- this._lastClick = { time: 0, x: 0, y: 0, detail: 0 };
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 now = Date.now();
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
- now - last.time < doubleClickMs &&
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
 
@@ -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: Date.now(),
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 Windows rung, first because it is the only one there: everything
191
- // below this reaches into `app.X`, and a tree that is not on an X server
192
- // has none. `GetLastInputInfo` answers for the whole session, which is what
193
- // an idle timeout means, so the shape is the polling rung's — with the
194
- // answer arriving synchronously instead of over a connection.
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 counter this process can read
297
- * without asking anybody: `GetLastInputInfo` is a call, not a round trip.
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
- const elapsed = watcher.session.app.lastInputMs?.();
307
- if (typeof elapsed !== 'number') return;
308
- const idle = elapsed >= watcher.timeout;
309
- watcher.set(idle);
310
- clearTimeout(watcher._timer);
311
- watcher._timer = setTimeout(
312
- () => pollLastInput(watcher),
313
- idle
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 () => watcher.listeners.delete(onChange);
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. On a display with neither it stays `false`,
34
- * which is the honest answer for a display that cannot be asked.
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
@@ -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;
@@ -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
  }