react-x11 2.0.0 → 2.0.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "react-x11",
3
- "version": "2.0.0",
3
+ "version": "2.0.1",
4
4
  "description": "react renderer with X11 as a target",
5
5
  "main": "./src/index.js",
6
6
  "files": [
@@ -82,7 +82,7 @@
82
82
  "node": ">=20.19"
83
83
  },
84
84
  "dependencies": {
85
- "ntk": "^8.3.0",
85
+ "ntk": "^8.3.1",
86
86
  "react-reconciler": "^0.33.0",
87
87
  "yoga-layout": "^3.2.1"
88
88
  },
@@ -5,7 +5,7 @@
5
5
  import React from 'react';
6
6
  import { changeEvent } from './change.js';
7
7
  import { Icon } from './Icon.js';
8
- import { labelContent, useControl, useTheme } from './theme.js';
8
+ import { focusRingStyle, labelContent, useControl, useTheme } from './theme.js';
9
9
 
10
10
  const h = React.createElement;
11
11
 
@@ -52,18 +52,28 @@ export function Checkbox({
52
52
  : hover
53
53
  ? theme.accentHover
54
54
  : theme.accent;
55
- // An empty well has no fill to step, so it shows the two states in two
56
- // different places: hovering firms the ring up, and pressing greys the
57
- // inside as well. Three looks, which is the point — a hover the press
58
- // cannot be told apart from says nothing about the press.
55
+ // An empty well has no fill to step, so it shows its states in two
56
+ // different places: focus is a *colour* on the border, hover and press are
57
+ // a *step* of the inside. Three looks, which is the point — a hover the
58
+ // press cannot be told apart from says nothing about the press — and four
59
+ // with focus, because the two channels compose instead of overwriting.
60
+ //
61
+ // They used to share the border, where hover won, and that made the focus
62
+ // colour unreachable by the gesture that focuses by pointer: a click ends
63
+ // with the pointer sitting on the control it just focused, so the well was
64
+ // hover-grey for as long as the user was looking at it and only turned
65
+ // blue once they moved the mouse away.
59
66
  const empty = {
60
- borderColor:
61
- pressed || hover
67
+ borderColor: focused
68
+ ? theme.borderFocus
69
+ : pressed || hover
62
70
  ? theme.textMuted
63
- : focused
64
- ? theme.borderFocus
65
- : theme.border,
66
- backgroundColor: pressed ? theme.surfaceActive : theme.surface,
71
+ : theme.border,
72
+ backgroundColor: pressed
73
+ ? theme.surfaceActive
74
+ : hover
75
+ ? theme.surfaceHover
76
+ : theme.surface,
67
77
  };
68
78
  return h(
69
79
  'box',
@@ -91,6 +101,9 @@ export function Checkbox({
91
101
  backgroundColor: checked ? fill : empty.backgroundColor,
92
102
  alignItems: 'center',
93
103
  justifyContent: 'center',
104
+ // a checked well is filled with the accent, so the border tier
105
+ // above has nothing left to say — see {@link focusRingStyle}
106
+ ...focusRingStyle(theme, checked && focused),
94
107
  },
95
108
  },
96
109
  checked &&
@@ -4,7 +4,7 @@
4
4
 
5
5
  import React, { useContext, useEffect, useMemo, useRef } from 'react';
6
6
  import { changeEvent } from './change.js';
7
- import { labelContent, useControl, useTheme } from './theme.js';
7
+ import { focusRingStyle, labelContent, useControl, useTheme } from './theme.js';
8
8
  import { XK_DOWN, XK_LEFT, XK_RIGHT, XK_UP } from './keys.js';
9
9
 
10
10
  const h = React.createElement;
@@ -109,16 +109,30 @@ export function Radio({ value, children, label, disabled = false }) {
109
109
  height: 16,
110
110
  borderRadius: 8,
111
111
  borderWidth: theme.borderWidth,
112
+ // Focus on the border, hover and press on the inside — the two
113
+ // channels Checkbox splits them into, and for the same reason: a
114
+ // click leaves the pointer on the control, so a focus colour hover
115
+ // can overwrite is one the pointer user never gets to see.
116
+ //
117
+ // A *selected* radio has spent its border on the fill, and the
118
+ // branch below it was unreachable — the one radio in a group that
119
+ // can be clicked without changing anything was also the only one
120
+ // with nothing to show for it. It gets the ring instead.
112
121
  borderColor: selected
113
122
  ? fill
114
- : pressed || hover
115
- ? theme.textMuted
116
- : focused
117
- ? theme.borderFocus
123
+ : focused
124
+ ? theme.borderFocus
125
+ : pressed || hover
126
+ ? theme.textMuted
118
127
  : theme.border,
119
- backgroundColor: pressed ? theme.surfaceActive : theme.surface,
128
+ backgroundColor: pressed
129
+ ? theme.surfaceActive
130
+ : hover
131
+ ? theme.surfaceHover
132
+ : theme.surface,
120
133
  alignItems: 'center',
121
134
  justifyContent: 'center',
135
+ ...focusRingStyle(theme, selected && focused),
122
136
  },
123
137
  },
124
138
  selected &&
@@ -334,6 +334,35 @@ export const capTrim = Object.freeze({ textBoxTrim: 'cap-alphabetic' });
334
334
  */
335
335
  export const capBand = (fontSize) => Math.round(fontSize * 0.72);
336
336
 
337
+ /**
338
+ * The focus ring, as style, for the one part of a widget that has to draw
339
+ * its own — or `null`, to be spread away, when it does not.
340
+ *
341
+ * Every empty control says focus in its border (`borderFocus`), which is the
342
+ * `:focus` tier styling.md asks for: a colour change welcome however focus
343
+ * arrived. A **filled** one — a checked box, a selected radio — has spent
344
+ * that border on the fill and has no colour left to spend, so it says the
345
+ * same thing one ring further out instead.
346
+ *
347
+ * On the part, not the row. The row already draws core's ring, but that one
348
+ * is the *keyboard* tier — `:focus-visible`, so it arrives on Tab and not on
349
+ * a click — and it wraps the label with the well. This is the other tier,
350
+ * and the two stack the same way the border colour and that ring already do.
351
+ *
352
+ * `undefined` for the width when off, never `0`: a node with no
353
+ * `outlineWidth` of its own is one core can leave out of the widened damage
354
+ * rects entirely (`_outlineExtent`, src/nodes.js), and `0` is the documented
355
+ * way to opt *out* of a ring the node would otherwise get.
356
+ */
357
+ export const focusRingStyle = (theme, on) =>
358
+ on
359
+ ? {
360
+ outlineWidth: theme.focusRingWidth,
361
+ outlineColor: theme.focusRing,
362
+ outlineOffset: theme.focusRingOffset,
363
+ }
364
+ : null;
365
+
337
366
  /**
338
367
  * The geometry of rows on a rounded sheet: how far a row sits inside the
339
368
  * sheet's edge, and how round its own corners are.
package/src/events.js CHANGED
@@ -13,7 +13,12 @@ import { callHandler, reportHandlerError } from './errors.js';
13
13
  import { armDrag } from './dnd.js';
14
14
  import { desktopSettings } from './desktopsettings.js';
15
15
  import { noteInputTime } from './inputtime.js';
16
- import { hooks as a11yHooks, isFocusable, effectivelyVisible } from './a11y.js';
16
+ import {
17
+ hooks as a11yHooks,
18
+ isFocusable,
19
+ isTextControl,
20
+ effectivelyVisible,
21
+ } from './a11y.js';
17
22
  import { Composer, composeTableFor } from './compose.js';
18
23
  import { acceleratorKeysym } from './keyboard.js';
19
24
  import { MOD } from './keysyms.js';
@@ -340,6 +345,11 @@ export class EventManager {
340
345
  // release that continue the same gesture follow
341
346
  this._downDefaulted = false;
342
347
  this.capturedNode = null;
348
+ // The cursor the window is already wearing. `null` rather than
349
+ // undefined on purpose: `null` *is* a cursor — X None, "inherit the
350
+ // parent's" — and it is the one a window starts with, so the first
351
+ // hover over a subtree that names no cursor has nothing to change.
352
+ this._appliedCursor = null;
343
353
  // Attention (ntk#37): the one node the pointer looks like it is heading
344
354
  // for, the recent pointer samples the trajectory is estimated from, and
345
355
  // the window's candidate registry held directly so the motion path costs
@@ -1497,6 +1507,12 @@ export class EventManager {
1497
1507
  * handing focus back as it closes, `node.focus()` from an application —
1498
1508
  * lights one, because none of those tell the user where focus went.
1499
1509
  *
1510
+ * With one exception, which is CSS's too: a **text control** clicked into
1511
+ * lights a ring anyway (`_ringsOnPress`). What the pointer user knows is
1512
+ * where they clicked, not that the next keystroke will land there, and a
1513
+ * clicked field is about to swallow the keyboard — so the one thing the
1514
+ * ring says that the click did not is exactly the thing worth saying.
1515
+ *
1500
1516
  * `backwards` is only meaningful for `reason: 'key'`, and only one element
1501
1517
  * has ever needed it: XEmbed distinguishes a Tab arriving forwards from a
1502
1518
  * back-Tab, because that is what tells an embedded client whether to focus
@@ -1548,7 +1564,10 @@ export class EventManager {
1548
1564
  // and back onto the window that owns it.
1549
1565
  if (!this.keyboardFocused && !restoring) this.node.window?.focus?.();
1550
1566
  node.setStyleState(':focus', true);
1551
- node.setStyleState(':focus-visible', reason !== 'pointer');
1567
+ node.setStyleState(
1568
+ ':focus-visible',
1569
+ reason !== 'pointer' || this._ringsOnPress(node),
1570
+ );
1552
1571
  // …and nothing scrolls to meet it either: the node is coming back to
1553
1572
  // the arrangement it left, and this reveal's layout has not run, so a
1554
1573
  // scroll here would be computed from a rect that does not exist yet.
@@ -1564,6 +1583,26 @@ export class EventManager {
1564
1583
  a11yHooks.focus?.(old, node);
1565
1584
  }
1566
1585
 
1586
+ /**
1587
+ * Whether a press lands `:focus-visible` on this node as well as `:focus`.
1588
+ *
1589
+ * The UA rule browsers settled on, and for the reason they settled on it:
1590
+ * a control the keyboard is about to talk to has to show that it holds the
1591
+ * keyboard, whichever way focus arrived. Clicking into a field puts the
1592
+ * caret somewhere the user chose and then makes every keystroke go there —
1593
+ * a state a button, a checkbox or a tab does not enter on a click, which
1594
+ * is why those still stay dark until Tab.
1595
+ *
1596
+ * Asked as "does this element hold editable text", not as "is its kind
1597
+ * `<textinput>`": `isTextControl` is the same predicate the AT-SPI
1598
+ * EDITABLE state is built on (src/a11y.js), so `<textarea>` and a
1599
+ * third-party editor reporting an editable text state answer yes on their
1600
+ * own — the two routes that make a node take the keyboard cannot drift.
1601
+ */
1602
+ _ringsOnPress(node) {
1603
+ return isTextControl(node);
1604
+ }
1605
+
1567
1606
  /**
1568
1607
  * Flip `:focus-within` over the focused node's ancestor chain, diffed the
1569
1608
  * way hover and the press chain are.
package/src/nodes.js CHANGED
@@ -262,6 +262,7 @@ const INVALIDATE_REASONS = new Set([
262
262
  'props', // a React commit changed what a node draws
263
263
  'style-state', // :hover/:focus/:active/:disabled restyle
264
264
  'shadow', // a boxShadow got smaller: where it *was* still owes a repaint
265
+ 'outline', // …and the same for an outline a style swap took away
265
266
  'theme', // a theme/token change restyled a subtree
266
267
  'direction', // the reading direction moved: sides, glyph order, bar edge
267
268
  'animation', // a transition frame
@@ -1707,6 +1708,26 @@ export class Node {
1707
1708
  );
1708
1709
  }
1709
1710
  }
1711
+ // An outline that just got smaller — or went away — owes the same debt,
1712
+ // and it is only ever owed here. Core's own ring rides `:focus-visible`,
1713
+ // where `EventManager.focus` claims the region while the ring is still
1714
+ // on; what arrives through a style swap is the `outlineWidth` escape
1715
+ // hatch (see `_outline`) — an application outlining a node for a reason
1716
+ // of its own, or a widget ringing one *part* of itself, the way
1717
+ // `<Checkbox>` rings its checked well and drops the ring again on blur.
1718
+ if (
1719
+ displayed.outlineWidth !== this.style.outlineWidth ||
1720
+ displayed.outlineOffset !== this.style.outlineOffset
1721
+ ) {
1722
+ const shrank = this._outlineExtent(displayed) - this._outlineExtent();
1723
+ if (shrank > 0) {
1724
+ this.root?.invalidate(
1725
+ false,
1726
+ insetRect(this.paintBounds(), -shrank),
1727
+ 'outline',
1728
+ );
1729
+ }
1730
+ }
1710
1731
  return this.style;
1711
1732
  }
1712
1733
 
@@ -3260,8 +3281,7 @@ export class Node {
3260
3281
  * costs nothing until it is asked for — which is once per focused node
3261
3282
  * per frame, not once per node per commit.
3262
3283
  */
3263
- _outline() {
3264
- const style = this.style;
3284
+ _outline(style = this.style) {
3265
3285
  const explicit = style.outlineWidth !== undefined;
3266
3286
  if (!explicit && !this._focusableForRing()) return null;
3267
3287
  const theme = this.theme;
@@ -3287,10 +3307,10 @@ export class Node {
3287
3307
  * *erases* a ring is the one case where the state has already flipped
3288
3308
  * back, and `EventManager.focus` claims the region before it does.
3289
3309
  */
3290
- _outlineExtent() {
3291
- if (this.style.outlineWidth === undefined && !this.states[':focus-visible'])
3310
+ _outlineExtent(style = this.style) {
3311
+ if (style.outlineWidth === undefined && !this.states[':focus-visible'])
3292
3312
  return 0;
3293
- const outline = this._outline();
3313
+ const outline = this._outline(style);
3294
3314
  return outline ? outline.width + Math.max(0, outline.offset) : 0;
3295
3315
  }
3296
3316
 
package/src/screens.js CHANGED
@@ -401,9 +401,6 @@ function watchLayout(session) {
401
401
  const X = session.app.X;
402
402
  const root = X.display?.screen?.[0]?.root;
403
403
  if (root == null) return;
404
- // PropertyChange on a window we do not own. Legal and shared — every
405
- // panel-aware application on the desktop selects this same event.
406
- X.ChangeWindowAttributes(root, { eventMask: PROPERTY_CHANGE_MASK }, () => {});
407
404
  session.onEvent((ev) => {
408
405
  if (session.stopped) return;
409
406
  if (ev.type !== PROPERTY_NOTIFY || ev.wid !== root) return;
@@ -411,6 +408,27 @@ function watchLayout(session) {
411
408
  relayout(session);
412
409
  });
413
410
  watchRandR(session);
411
+ // PropertyChange on a window we do not own. Legal and shared — every
412
+ // panel-aware application on the desktop selects this same event.
413
+ //
414
+ // Through ntk's root Window rather than as a raw ChangeWindowAttributes,
415
+ // because an X event mask is absolute per client: whoever writes last wins
416
+ // outright. ntk keeps selections additive by tracking the mask on a Window
417
+ // it caches per id, and adopting the root — which the shared-glyph
418
+ // directory does to hear MANAGER announcements — starts that tracked mask
419
+ // at zero and writes StructureNotify over whatever was there. A raw write
420
+ // is a value the next adopter silently overwrites, in either direction:
421
+ // reversed, it is a window manager's SubstructureRedirect that goes.
422
+ //
423
+ // Last in this function on purpose. `rootWindow()` constructs a Window,
424
+ // which can throw, and `beginScreens` calls this inside a bare catch — so
425
+ // ahead of the two registrations above, a throw here would take the
426
+ // work-area handler and the RandR watch with it rather than only the
427
+ // selection it belongs to.
428
+ session.app
429
+ .rootWindow()
430
+ .selectInput(PROPERTY_CHANGE_MASK)
431
+ .catch(() => {});
414
432
  }
415
433
 
416
434
  /** Re-read everything the layout is built from and publish once. */
@@ -43,6 +43,7 @@ import { useTopLevelWindow, windowIdOf } from './windowid.js';
43
43
 
44
44
  const VISIBILITY_NOTIFY = 15;
45
45
  const VISIBILITY_CHANGE_MASK = 65536; // x11.eventMask.VisibilityChange
46
+ const PROPERTY_CHANGE_MASK = 4194304; // x11.eventMask.PropertyChange
46
47
  const FULLY_OBSCURED = 2;
47
48
  const DESKTOP_PROPERTY = '_NET_WM_DESKTOP';
48
49
 
@@ -198,6 +199,23 @@ function arm(session) {
198
199
  if (!wnd) return;
199
200
  session.armed = true;
200
201
 
202
+ // **One selection for everything this session needs.** Two masks are at
203
+ // stake — PropertyChange for `_NET_WM_STATE`/`_NET_WM_DESKTOP`, and
204
+ // VisibilityChange for the obscured watch — and ntk grows a window's mask
205
+ // one request per first listener of each kind. Asking for both here, before
206
+ // either subscription, makes the `statechange` listener below a detected
207
+ // no-op and leaves `watchVisibility` nothing to select: one
208
+ // ChangeWindowAttributes for a session instead of two, on a window that is
209
+ // already mapped and on screen by the time any of this runs.
210
+ if (typeof wnd.selectInput === 'function') {
211
+ Promise.resolve(
212
+ wnd.selectInput(PROPERTY_CHANGE_MASK | VISIBILITY_CHANGE_MASK),
213
+ ).catch(() => {
214
+ // a window destroyed between the ref resolving and this call; the
215
+ // defaults stand and nothing further will arrive
216
+ });
217
+ }
218
+
201
219
  // Focus, from the event manager rather than from ntk directly: it already
202
220
  // dedups FocusIn/FocusOut and already knows the answer for a window that
203
221
  // has not seen either yet.
@@ -269,9 +287,9 @@ function readDesktop(session) {
269
287
 
270
288
  /**
271
289
  * VisibilityNotify, which ntk has no event name for — its mask table stops
272
- * at the events a widget toolkit needs — so the mask goes on through
273
- * `selectInput` (which ORs into ntk's own tracked mask, so nothing it adds
274
- * later drops this) and the event is read off the raw connection.
290
+ * at the events a widget toolkit needs — so the event is read off the raw
291
+ * connection. The mask it needs was selected by `arm()`, together with the
292
+ * one the state watch needs.
275
293
  */
276
294
  function watchVisibility(session) {
277
295
  const wnd = session.node.window;
@@ -285,11 +303,6 @@ function watchVisibility(session) {
285
303
  };
286
304
  X.on('event', onEvent);
287
305
  session._offs.push(() => X.off?.('event', onEvent));
288
-
289
- Promise.resolve(wnd.selectInput(VISIBILITY_CHANGE_MASK)).catch(() => {
290
- // a window destroyed between the ref resolving and this call; the
291
- // defaults stand and nothing further will arrive
292
- });
293
306
  }
294
307
 
295
308
  /** What is known about a window right now. Stable until it changes. */