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 +2 -2
- package/src/components/Checkbox.js +24 -11
- package/src/components/Radio.js +20 -6
- package/src/components/theme.js +29 -0
- package/src/events.js +41 -2
- package/src/nodes.js +25 -5
- package/src/screens.js +21 -3
- package/src/windowstate.js +21 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "react-x11",
|
|
3
|
-
"version": "2.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.
|
|
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
|
|
56
|
-
// different places:
|
|
57
|
-
//
|
|
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
|
-
|
|
67
|
+
borderColor: focused
|
|
68
|
+
? theme.borderFocus
|
|
69
|
+
: pressed || hover
|
|
62
70
|
? theme.textMuted
|
|
63
|
-
:
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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 &&
|
package/src/components/Radio.js
CHANGED
|
@@ -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
|
-
:
|
|
115
|
-
? theme.
|
|
116
|
-
:
|
|
117
|
-
? theme.
|
|
123
|
+
: focused
|
|
124
|
+
? theme.borderFocus
|
|
125
|
+
: pressed || hover
|
|
126
|
+
? theme.textMuted
|
|
118
127
|
: theme.border,
|
|
119
|
-
backgroundColor: pressed
|
|
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 &&
|
package/src/components/theme.js
CHANGED
|
@@ -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 {
|
|
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(
|
|
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 (
|
|
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. */
|
package/src/windowstate.js
CHANGED
|
@@ -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
|
|
273
|
-
*
|
|
274
|
-
*
|
|
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. */
|