react-x11 2.2.1 → 2.3.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/README.md +5 -3
- package/package.json +12 -5
- package/src/Reconciler.js +76 -11
- package/src/anchor.js +15 -5
- package/src/appcontext.js +27 -7
- package/src/cocoa/app.js +557 -0
- package/src/cocoa/bezels.js +161 -0
- package/src/cocoa/context2d.js +679 -0
- package/src/cocoa/fonts.js +541 -0
- package/src/cocoa/glarea.js +321 -0
- package/src/cocoa/globalmenu.js +149 -0
- package/src/cocoa/keymap.js +86 -0
- package/src/cocoa/native.js +51 -0
- package/src/cocoa/panehost.js +50 -0
- package/src/cocoa/panewindow.js +236 -0
- package/src/cocoa/presenter.js +837 -0
- package/src/cocoa/window.js +383 -0
- package/src/components/Button.js +68 -0
- package/src/components/Checkbox.js +43 -0
- package/src/components/Radio.js +37 -1
- package/src/components/Select.js +73 -28
- package/src/components/Slider.js +59 -0
- package/src/components/Switch.js +52 -0
- package/src/components/native.js +142 -0
- package/src/decorations.js +21 -24
- package/src/editmenu.js +23 -14
- package/src/events.js +11 -2
- package/src/frame/childmain.js +9 -0
- package/src/frame/index.js +162 -2
- package/src/glnodes.js +43 -0
- package/src/globalmenu.js +11 -3
- package/src/index.d.ts +10 -0
- package/src/node.d.ts +19 -6
- package/src/nodes.js +107 -33
- package/src/paintcache.js +15 -4
- package/src/palette.js +14 -0
- package/src/styles.js +5 -0
- package/src/testing/index.d.ts +8 -0
- package/src/types/elements.d.ts +10 -0
- package/src/types/events.d.ts +5 -2
package/src/components/Select.js
CHANGED
|
@@ -3,8 +3,16 @@
|
|
|
3
3
|
// build-step-free for consumers.
|
|
4
4
|
|
|
5
5
|
import React, { useEffect, useRef, useState } from 'react';
|
|
6
|
+
import { useAppOrNull } from '../appcontext.js';
|
|
6
7
|
import { capBand, capTrim, rowRadius, useTheme } from './theme.js';
|
|
7
8
|
import { Icon } from './Icon.js';
|
|
9
|
+
import {
|
|
10
|
+
ABS_FILL,
|
|
11
|
+
Bezel,
|
|
12
|
+
bezelNatural,
|
|
13
|
+
pressWash,
|
|
14
|
+
useNativeControls,
|
|
15
|
+
} from './native.js';
|
|
8
16
|
import { changeEvent } from './change.js';
|
|
9
17
|
import {
|
|
10
18
|
anchorArea,
|
|
@@ -184,10 +192,13 @@ export function Select({
|
|
|
184
192
|
onChange,
|
|
185
193
|
name,
|
|
186
194
|
placeholder = 'Select…',
|
|
195
|
+
native,
|
|
187
196
|
style,
|
|
188
197
|
...boxProps
|
|
189
198
|
}) {
|
|
190
199
|
const theme = useTheme();
|
|
200
|
+
const app = useAppOrNull();
|
|
201
|
+
const nativeControls = useNativeControls(native);
|
|
191
202
|
const [open, setOpen] = useState(false);
|
|
192
203
|
const [anchor, setAnchor] = useState(null);
|
|
193
204
|
const [focused, setFocused] = useState(false);
|
|
@@ -336,25 +347,38 @@ export function Select({
|
|
|
336
347
|
onKeyDown,
|
|
337
348
|
...boxProps,
|
|
338
349
|
style: [
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
350
|
+
nativeControls
|
|
351
|
+
? // The native popup bezel carries the border, the fill and the
|
|
352
|
+
// arrow capsule, so the trigger keeps only its row layout and
|
|
353
|
+
// AppKit's own height. The right padding clears the arrows.
|
|
354
|
+
{
|
|
355
|
+
cursor: 'pointer',
|
|
356
|
+
flexDirection: 'row',
|
|
357
|
+
alignItems: 'center',
|
|
358
|
+
gap: 8,
|
|
359
|
+
height: bezelNatural(app, 'popup').height,
|
|
360
|
+
paddingLeft: 10,
|
|
361
|
+
paddingRight: 26,
|
|
362
|
+
}
|
|
363
|
+
: {
|
|
364
|
+
cursor: 'pointer',
|
|
365
|
+
flexDirection: 'row',
|
|
366
|
+
alignItems: 'center',
|
|
367
|
+
gap: 8,
|
|
368
|
+
// The vertical padding is the palette's, the same one a `<Button>`
|
|
369
|
+
// takes, because these are controls of one family and a form puts
|
|
370
|
+
// them in a row together. Horizontally it is its own, tighter
|
|
371
|
+
// number: a dropdown is a field with a value in it, not a button
|
|
372
|
+
// with a word centred on it.
|
|
373
|
+
paddingTop: theme.paddingY,
|
|
374
|
+
paddingBottom: theme.paddingY,
|
|
375
|
+
paddingLeft: 10,
|
|
376
|
+
paddingRight: 10,
|
|
377
|
+
borderWidth: theme.borderWidth,
|
|
378
|
+
borderRadius: theme.radius,
|
|
379
|
+
borderColor: focused || open ? theme.borderFocus : theme.border,
|
|
380
|
+
backgroundColor: theme.surface,
|
|
381
|
+
},
|
|
358
382
|
// Hover and press belong to the trigger while it is *shut*: they say
|
|
359
383
|
// "this opens". Once the menu is down that is answered, and the
|
|
360
384
|
// trigger's job is to read as one surface with the popup hanging off
|
|
@@ -364,13 +388,25 @@ export function Select({
|
|
|
364
388
|
// state block always outranks the base style, so there is no colour
|
|
365
389
|
// the open state could put in `backgroundColor` that `:hover` would
|
|
366
390
|
// not overwrite. The only way for open to win is to not be competing.
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
391
|
+
// (In native mode the wash overlay below answers the press instead.)
|
|
392
|
+
!nativeControls &&
|
|
393
|
+
!open && {
|
|
394
|
+
':hover': { backgroundColor: theme.surfaceHover },
|
|
395
|
+
':active': { backgroundColor: theme.surfaceActive },
|
|
396
|
+
},
|
|
371
397
|
style,
|
|
372
398
|
],
|
|
373
399
|
},
|
|
400
|
+
// `pressed` while the menu is down: AppKit's popup answers being open
|
|
401
|
+
// by highlighting the arrow capsule, which is the open look this
|
|
402
|
+
// trigger otherwise lost with its borderFocus.
|
|
403
|
+
nativeControls &&
|
|
404
|
+
h(Bezel, {
|
|
405
|
+
kind: 'popup',
|
|
406
|
+
pressed: open,
|
|
407
|
+
enabled: true,
|
|
408
|
+
style: ABS_FILL,
|
|
409
|
+
}),
|
|
374
410
|
h(
|
|
375
411
|
'text',
|
|
376
412
|
{ style: [capTrim, { color: current ? theme.text : theme.textMuted }] },
|
|
@@ -382,11 +418,20 @@ export function Select({
|
|
|
382
418
|
// row have to be one height, and they only are if the tallest thing in
|
|
383
419
|
// each is measured the same way. A glyph taller than the cap band makes
|
|
384
420
|
// the dropdown alone two pixels taller than everything next to it.
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
421
|
+
// The native bezel draws its own arrow capsule instead.
|
|
422
|
+
nativeControls
|
|
423
|
+
? h('box', {
|
|
424
|
+
style: [
|
|
425
|
+
ABS_FILL,
|
|
426
|
+
{ borderRadius: 6 },
|
|
427
|
+
!open && { ':active': { backgroundColor: pressWash(theme) } },
|
|
428
|
+
],
|
|
429
|
+
})
|
|
430
|
+
: h(Icon, {
|
|
431
|
+
name: 'chevronDown',
|
|
432
|
+
size: capBand(theme.fontSize),
|
|
433
|
+
color: theme.textMuted,
|
|
434
|
+
}),
|
|
390
435
|
open &&
|
|
391
436
|
anchor &&
|
|
392
437
|
h(
|
package/src/components/Slider.js
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
// build-step-free for consumers.
|
|
4
4
|
|
|
5
5
|
import React, { useRef, useState } from 'react';
|
|
6
|
+
import { useAppOrNull } from '../appcontext.js';
|
|
7
|
+
import { Bezel, bezelNatural, useNativeControls } from './native.js';
|
|
6
8
|
import { useTheme } from './theme.js';
|
|
7
9
|
import { changeEvent } from './change.js';
|
|
8
10
|
import {
|
|
@@ -42,10 +44,13 @@ export function Slider({
|
|
|
42
44
|
name,
|
|
43
45
|
disabled = false,
|
|
44
46
|
height = 4,
|
|
47
|
+
native,
|
|
45
48
|
style,
|
|
46
49
|
...boxProps
|
|
47
50
|
}) {
|
|
48
51
|
const theme = useTheme();
|
|
52
|
+
const app = useAppOrNull();
|
|
53
|
+
const nativeControls = useNativeControls(native);
|
|
49
54
|
const [focused, setFocused] = useState(false);
|
|
50
55
|
const [dragging, setDragging] = useState(false);
|
|
51
56
|
const trackRef = useRef(null);
|
|
@@ -152,6 +157,60 @@ export function Slider({
|
|
|
152
157
|
},
|
|
153
158
|
};
|
|
154
159
|
|
|
160
|
+
// The whole control as one NSSlider render, keyed by the value so a drag
|
|
161
|
+
// is a sequence of cached-or-rendered bezels. Pointer math, keyboard and
|
|
162
|
+
// a11y are the shared implementation above — the bezel is presentation.
|
|
163
|
+
//
|
|
164
|
+
// The *small* control size on purpose: its 16pt knob is exactly the
|
|
165
|
+
// drawn thumb's footprint, so rows laid out for the drawn slider fit,
|
|
166
|
+
// and the pointer math shares one thumb constant across both modes —
|
|
167
|
+
// the regular size's 20pt knob overflowed every compact row it met.
|
|
168
|
+
if (nativeControls) {
|
|
169
|
+
const nat = bezelNatural(app, 'slider', 'small');
|
|
170
|
+
return h(
|
|
171
|
+
'box',
|
|
172
|
+
{
|
|
173
|
+
theme,
|
|
174
|
+
role: 'slider',
|
|
175
|
+
'aria-valuenow': clamp(value),
|
|
176
|
+
'aria-valuemin': min,
|
|
177
|
+
'aria-valuemax': max,
|
|
178
|
+
'aria-orientation': 'horizontal',
|
|
179
|
+
ref: trackRef,
|
|
180
|
+
...controlProps,
|
|
181
|
+
...boxProps,
|
|
182
|
+
style: [
|
|
183
|
+
disabled || { cursor: 'pointer' },
|
|
184
|
+
{
|
|
185
|
+
height: nat.height,
|
|
186
|
+
minWidth: 0,
|
|
187
|
+
hitSlop: {
|
|
188
|
+
top: Math.max(0, (24 - nat.height) / 2),
|
|
189
|
+
bottom: Math.max(0, (24 - nat.height) / 2),
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
style,
|
|
193
|
+
],
|
|
194
|
+
},
|
|
195
|
+
h(Bezel, {
|
|
196
|
+
kind: 'slider',
|
|
197
|
+
controlSize: 'small',
|
|
198
|
+
// quantized so a drag reuses cache entries instead of minting one
|
|
199
|
+
// per sub-pixel float; 400 steps is finer than any track is wide
|
|
200
|
+
value: Math.round(fraction * 400) / 400,
|
|
201
|
+
enabled: !disabled,
|
|
202
|
+
style: {
|
|
203
|
+
position: 'absolute',
|
|
204
|
+
top: 0,
|
|
205
|
+
right: 0,
|
|
206
|
+
bottom: 0,
|
|
207
|
+
left: 0,
|
|
208
|
+
pointerEvents: 'none',
|
|
209
|
+
},
|
|
210
|
+
}),
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
155
214
|
return h(
|
|
156
215
|
'box',
|
|
157
216
|
{
|
package/src/components/Switch.js
CHANGED
|
@@ -3,8 +3,16 @@
|
|
|
3
3
|
// build-step-free for consumers.
|
|
4
4
|
|
|
5
5
|
import React from 'react';
|
|
6
|
+
import { useAppOrNull } from '../appcontext.js';
|
|
6
7
|
import { createStyles } from '../styles.js';
|
|
7
8
|
import { changeEvent } from './change.js';
|
|
9
|
+
import {
|
|
10
|
+
ABS_FILL,
|
|
11
|
+
Bezel,
|
|
12
|
+
bezelNatural,
|
|
13
|
+
pressWash,
|
|
14
|
+
useNativeControls,
|
|
15
|
+
} from './native.js';
|
|
8
16
|
import { useControl, useTheme } from './theme.js';
|
|
9
17
|
|
|
10
18
|
const h = React.createElement;
|
|
@@ -74,15 +82,59 @@ export function Switch({
|
|
|
74
82
|
onChange,
|
|
75
83
|
name,
|
|
76
84
|
disabled = false,
|
|
85
|
+
native,
|
|
77
86
|
style,
|
|
78
87
|
...boxProps
|
|
79
88
|
}) {
|
|
80
89
|
const theme = useTheme();
|
|
90
|
+
const app = useAppOrNull();
|
|
91
|
+
const nativeControls = useNativeControls(native);
|
|
81
92
|
const control = useControl(
|
|
82
93
|
disabled,
|
|
83
94
|
() => onChange?.(changeEvent('checkbox', name, !checked)),
|
|
84
95
|
{ styled: true },
|
|
85
96
|
);
|
|
97
|
+
|
|
98
|
+
// NSSwitch's own pixels, on/off as two cached bezels. The knob slide is
|
|
99
|
+
// the fidelity this loses for now (docs/macos.md flags the switch as the
|
|
100
|
+
// one control worth revisiting if that disappoints); the press keeps its
|
|
101
|
+
// answer as the wash, since NSSwitch renders no pressed state offscreen.
|
|
102
|
+
if (nativeControls) {
|
|
103
|
+
const nat = bezelNatural(app, 'switch');
|
|
104
|
+
return h(
|
|
105
|
+
'box',
|
|
106
|
+
{
|
|
107
|
+
theme,
|
|
108
|
+
role: 'switch',
|
|
109
|
+
'aria-checked': checked,
|
|
110
|
+
...control.props,
|
|
111
|
+
...boxProps,
|
|
112
|
+
style: [
|
|
113
|
+
control.style,
|
|
114
|
+
{
|
|
115
|
+
width: nat.width,
|
|
116
|
+
height: nat.height,
|
|
117
|
+
hitSlop: Math.max(0, (24 - nat.height) / 2),
|
|
118
|
+
},
|
|
119
|
+
style,
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
h(Bezel, {
|
|
123
|
+
kind: 'switch',
|
|
124
|
+
state: checked ? 1 : 0,
|
|
125
|
+
enabled: !disabled,
|
|
126
|
+
style: ABS_FILL,
|
|
127
|
+
}),
|
|
128
|
+
h('box', {
|
|
129
|
+
style: [
|
|
130
|
+
ABS_FILL,
|
|
131
|
+
{ borderRadius: nat.height / 2 },
|
|
132
|
+
!disabled && { ':active': { backgroundColor: pressWash(theme) } },
|
|
133
|
+
],
|
|
134
|
+
}),
|
|
135
|
+
);
|
|
136
|
+
}
|
|
137
|
+
|
|
86
138
|
return h(
|
|
87
139
|
'box',
|
|
88
140
|
{
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
// The native half of the widget set (docs/macos.md §Native controls).
|
|
2
|
+
//
|
|
3
|
+
// House rule: **default native, seam out.** On a backend that renders the
|
|
4
|
+
// platform's own control bezels — today the Cocoa backend — the core
|
|
5
|
+
// controls wear AppKit's pixels while interaction, focus, keyboard and
|
|
6
|
+
// a11y stay the shared implementation. The mechanism is a bezel *image*
|
|
7
|
+
// drawn through the ordinary paint path, never an embedded platform view:
|
|
8
|
+
// the press model, the focus rules and the event routing are behaviour this
|
|
9
|
+
// project considers part of its identity, and a real NSControl would take
|
|
10
|
+
// them over wholesale.
|
|
11
|
+
//
|
|
12
|
+
// The policy lives in the theme (`controls: 'auto' | 'native' | 'drawn'`),
|
|
13
|
+
// because whether an app looks native is an app-identity decision, not a
|
|
14
|
+
// per-node style. The per-instance escape hatch is `native={false}` on the
|
|
15
|
+
// one custom-branded control — a themed `style` override is ignored by a
|
|
16
|
+
// native bezel, so a control that names its own colours should also name
|
|
17
|
+
// `native={false}` (the widgets keep today's drawn rendering everywhere the
|
|
18
|
+
// bezel is off, so custom-designed apps lose nothing).
|
|
19
|
+
|
|
20
|
+
import React, { useCallback } from 'react';
|
|
21
|
+
import { useAppOrNull } from '../appcontext.js';
|
|
22
|
+
import { useTheme } from './theme.js';
|
|
23
|
+
|
|
24
|
+
const h = React.createElement;
|
|
25
|
+
|
|
26
|
+
let warnedUnsupported = false;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Should this control render the platform bezel? Combines the per-instance
|
|
30
|
+
* `native` prop, the theme's `controls` policy and the backend capability.
|
|
31
|
+
* An explicit `controls: 'native'` on a backend without the capability
|
|
32
|
+
* warns once and draws — erroring would make the same app code illegal on
|
|
33
|
+
* X11, which is the opposite of what a cross-backend widget set is for.
|
|
34
|
+
*/
|
|
35
|
+
export function useNativeControls(native) {
|
|
36
|
+
const app = useAppOrNull();
|
|
37
|
+
const theme = useTheme();
|
|
38
|
+
const capable = Boolean(app?.nativeBezels);
|
|
39
|
+
if (native === false) return false;
|
|
40
|
+
const mode = native === true ? 'native' : (theme.controls ?? 'auto');
|
|
41
|
+
if (mode === 'drawn') return false;
|
|
42
|
+
if (mode === 'native' && !capable && !warnedUnsupported) {
|
|
43
|
+
warnedUnsupported = true;
|
|
44
|
+
console.warn(
|
|
45
|
+
"react-x11: controls: 'native' — this backend has no native control " +
|
|
46
|
+
'rendering; the themed drawn controls are used instead.',
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
return capable;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The control's natural size in logical px — AppKit's own metrics, which
|
|
54
|
+
* native-mode layout adopts (a stretched checkbox is a wrong checkbox).
|
|
55
|
+
* `null` where there is no bezel store, so callers can guard in one step.
|
|
56
|
+
*/
|
|
57
|
+
export function bezelNatural(app, kind, controlSize = 'regular') {
|
|
58
|
+
return app?.nativeBezels?.natural(kind, controlSize) ?? null;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Absolute fill inside the control's box — where every bezel layer goes. */
|
|
62
|
+
export const ABS_FILL = Object.freeze({
|
|
63
|
+
position: 'absolute',
|
|
64
|
+
top: 0,
|
|
65
|
+
right: 0,
|
|
66
|
+
bottom: 0,
|
|
67
|
+
left: 0,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The press wash — the one hover/press affordance native mode keeps.
|
|
72
|
+
* AppKit's controls have no hover state, so hover tints are deliberately
|
|
73
|
+
* absent here; the press is answered with a translucent wash over the
|
|
74
|
+
* bezel, dark on light and light on dark, the direction macOS itself steps
|
|
75
|
+
* a pressed control.
|
|
76
|
+
*/
|
|
77
|
+
export const pressWash = (theme) =>
|
|
78
|
+
theme.scheme === 'dark' ? 'rgba(255,255,255,0.12)' : 'rgba(0,0,0,0.07)';
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `<Bezel kind …states style>` — one AppKit bezel, rendered at exactly the
|
|
82
|
+
* box layout gives it. A `<canvas>` under the hood: the bezel comes out of
|
|
83
|
+
* the app's `BezelStore` (cached by every parameter that changes the
|
|
84
|
+
* pixels) and is blitted with the 9-arg `drawImage`, so a state change is a
|
|
85
|
+
* cache lookup and one blit, bounded to this node's damage.
|
|
86
|
+
*/
|
|
87
|
+
export function Bezel({
|
|
88
|
+
kind,
|
|
89
|
+
state = 0,
|
|
90
|
+
pressed = false,
|
|
91
|
+
enabled = true,
|
|
92
|
+
isDefault = false,
|
|
93
|
+
value,
|
|
94
|
+
controlSize = 'regular',
|
|
95
|
+
style,
|
|
96
|
+
}) {
|
|
97
|
+
const app = useAppOrNull();
|
|
98
|
+
const theme = useTheme();
|
|
99
|
+
// The theme in force decides the bezel's appearance, not the desktop: a
|
|
100
|
+
// pinned-light app on a dark desktop gets light bezels beside its light
|
|
101
|
+
// surfaces. `scheme` is the palette's own statement of which it is.
|
|
102
|
+
const appearance = theme.scheme === 'dark' ? 'dark' : 'light';
|
|
103
|
+
const params = {
|
|
104
|
+
kind,
|
|
105
|
+
controlSize,
|
|
106
|
+
state: state ? 1 : 0,
|
|
107
|
+
pressed: Boolean(pressed),
|
|
108
|
+
enabled: Boolean(enabled),
|
|
109
|
+
isDefault: Boolean(isDefault),
|
|
110
|
+
appearance,
|
|
111
|
+
};
|
|
112
|
+
if (value !== undefined) params.value = value;
|
|
113
|
+
const sig = JSON.stringify(params);
|
|
114
|
+
const store = app?.nativeBezels;
|
|
115
|
+
// Keyed on the parameter signature so an unchanged bezel keeps its
|
|
116
|
+
// `onDraw` identity — CanvasNode invalidates when the closure changes,
|
|
117
|
+
// and a Button re-rendered by its parent must not repaint its bezel.
|
|
118
|
+
const onDraw = useCallback(
|
|
119
|
+
(ctx, info) => {
|
|
120
|
+
if (!store || !info.width || !info.height) return;
|
|
121
|
+
const bezel = store.get(
|
|
122
|
+
JSON.parse(sig),
|
|
123
|
+
info.width,
|
|
124
|
+
info.height,
|
|
125
|
+
info.scale,
|
|
126
|
+
);
|
|
127
|
+
ctx.drawImage(
|
|
128
|
+
{ _surfaceHandle: bezel.surface },
|
|
129
|
+
bezel.sx,
|
|
130
|
+
bezel.sy,
|
|
131
|
+
bezel.sw,
|
|
132
|
+
bezel.sh,
|
|
133
|
+
0,
|
|
134
|
+
0,
|
|
135
|
+
info.width,
|
|
136
|
+
info.height,
|
|
137
|
+
);
|
|
138
|
+
},
|
|
139
|
+
[store, sig],
|
|
140
|
+
);
|
|
141
|
+
return h('canvas', { onDraw, style });
|
|
142
|
+
}
|
package/src/decorations.js
CHANGED
|
@@ -30,6 +30,12 @@
|
|
|
30
30
|
// - `textShadow`: per glyph, through the glyph cache, and much rarer in a
|
|
31
31
|
// desktop UI.
|
|
32
32
|
|
|
33
|
+
// The kernel maths is ntk's: `blurCoverage` runs the blur, so the reach it
|
|
34
|
+
// derives is the padding a coverage surface has to carry. Both are pure
|
|
35
|
+
// functions — no connection, nothing drawn — so this module stays testable
|
|
36
|
+
// without a server.
|
|
37
|
+
import { shadowReach, shadowSigma } from 'ntk';
|
|
38
|
+
|
|
33
39
|
/** A number, with or without the `px` CSS wants and this vocabulary does not. */
|
|
34
40
|
const LENGTH = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:px)?$/i;
|
|
35
41
|
/** …and the same thing as a percentage, which colour stops also take. */
|
|
@@ -366,36 +372,27 @@ function readShadow(part, value) {
|
|
|
366
372
|
}
|
|
367
373
|
|
|
368
374
|
/**
|
|
369
|
-
* The gaussian a
|
|
375
|
+
* The gaussian a `blur` asks for, and the padding its coverage surface needs.
|
|
370
376
|
*
|
|
371
|
-
* CSS defines the blur radius as *twice* the standard deviation,
|
|
372
|
-
*
|
|
373
|
-
*
|
|
374
|
-
*
|
|
375
|
-
*
|
|
376
|
-
* `pad` is the same distance, and it is what stops the blur clipping square
|
|
377
|
-
* against the edge of the surface it was rendered into.
|
|
377
|
+
* CSS defines the blur radius as *twice* the standard deviation, so the two
|
|
378
|
+
* names that look like they match are the two that must not be passed to
|
|
379
|
+
* each other; `shadowSigma` is the halving, and it applies ntk's policy cap
|
|
380
|
+
* so a 4000px blur asked for by accident cannot become a kernel nothing
|
|
381
|
+
* finishes.
|
|
378
382
|
*
|
|
379
|
-
* `
|
|
380
|
-
*
|
|
381
|
-
*
|
|
383
|
+
* `pad` is the reach of the kernel **ntk will actually run** — that is why
|
|
384
|
+
* it comes from `shadowReach` rather than from arithmetic of our own. The
|
|
385
|
+
* blur is baked by `blurCoverage`, which derives its own kernel from sigma,
|
|
386
|
+
* and a surface padded by less than that reach ends the shadow in a straight
|
|
387
|
+
* line where the kernel ran out of pixels. The two numbers have to agree and
|
|
388
|
+
* nothing about a wrong answer looks like a bug, so they come from one
|
|
389
|
+
* place. The extra pixel is slack against the rounding above.
|
|
382
390
|
*/
|
|
383
|
-
const MAX_KERNEL = 61;
|
|
384
|
-
|
|
385
391
|
export function blurKernel(blur) {
|
|
386
|
-
const sigma = blur
|
|
387
|
-
|
|
388
|
-
const size = Math.min(MAX_KERNEL, 2 * reach + 1);
|
|
389
|
-
return { sigma, size, pad: (size - 1) / 2 + 1 };
|
|
392
|
+
const sigma = shadowSigma(blur);
|
|
393
|
+
return { sigma, pad: sigma > 0 ? shadowReach(sigma) + 1 : 0 };
|
|
390
394
|
}
|
|
391
395
|
|
|
392
|
-
/**
|
|
393
|
-
* How far outside the node's own box this shadow list reaches, in device
|
|
394
|
-
* pixels — the damage inflation, and the reason a shadow is not just a
|
|
395
|
-
* colour. Symmetric on purpose: a shadow offset down and right claims the
|
|
396
|
-
* same slack above and left, which costs a few pixels of repaint and saves
|
|
397
|
-
* every caller from carrying four numbers around.
|
|
398
|
-
*/
|
|
399
396
|
export function shadowExtent(shadows) {
|
|
400
397
|
let extent = 0;
|
|
401
398
|
for (const s of shadows ?? []) {
|
package/src/editmenu.js
CHANGED
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
*/
|
|
22
22
|
import { formatShortcut, isSeparator } from './menuitem.js';
|
|
23
23
|
|
|
24
|
+
// Logical px, multiplied by the display scale where they are used: the
|
|
25
|
+
// menu's text is measured and painted in device pixels (fonts are sized on
|
|
26
|
+
// the device grid), so the chrome around it has to live on the same grid —
|
|
27
|
+
// unscaled constants against scaled glyph ink is rows shorter than their
|
|
28
|
+
// own labels (the compact-menu bug, first seen on the 2x Cocoa backend).
|
|
24
29
|
const ROW_HEIGHT = 22;
|
|
25
30
|
const SEPARATOR_HEIGHT = 7;
|
|
26
31
|
const PAD_X = 10;
|
|
@@ -143,33 +148,35 @@ export function editMenuItems(actions = {}, { canPaste = true } = {}) {
|
|
|
143
148
|
* @param {(text: string) => number|null} [measure] text width, or null when
|
|
144
149
|
* nothing can be measured yet
|
|
145
150
|
*/
|
|
146
|
-
export function editMenuGeometry(items, measure) {
|
|
151
|
+
export function editMenuGeometry(items, measure, scale = 1) {
|
|
147
152
|
const widthOf = (text) => {
|
|
148
153
|
if (!text) return 0;
|
|
149
154
|
const w = measure?.(text);
|
|
150
155
|
return typeof w === 'number' && w > 0
|
|
151
156
|
? w
|
|
152
|
-
: text.length * FALLBACK_CHAR_WIDTH;
|
|
157
|
+
: text.length * FALLBACK_CHAR_WIDTH * scale;
|
|
153
158
|
};
|
|
154
159
|
const rows = [];
|
|
155
|
-
let y = PAD_Y;
|
|
160
|
+
let y = PAD_Y * scale;
|
|
156
161
|
let widest = 0;
|
|
157
162
|
for (const item of items) {
|
|
158
163
|
const separator = isSeparator(item);
|
|
159
164
|
const shortcut = formatShortcut(item.shortcut);
|
|
160
|
-
const height = separator ? SEPARATOR_HEIGHT : ROW_HEIGHT;
|
|
165
|
+
const height = (separator ? SEPARATOR_HEIGHT : ROW_HEIGHT) * scale;
|
|
161
166
|
rows.push({ ...item, separator, shortcut, y, height });
|
|
162
167
|
y += height;
|
|
163
168
|
if (separator) continue;
|
|
164
169
|
widest = Math.max(
|
|
165
170
|
widest,
|
|
166
|
-
widthOf(item.label) +
|
|
171
|
+
widthOf(item.label) +
|
|
172
|
+
(shortcut ? SHORTCUT_GAP * scale + widthOf(shortcut) : 0),
|
|
167
173
|
);
|
|
168
174
|
}
|
|
169
175
|
return {
|
|
170
176
|
rows,
|
|
171
|
-
|
|
172
|
-
|
|
177
|
+
scale,
|
|
178
|
+
width: Math.max(MIN_WIDTH * scale, Math.ceil(widest + PAD_X * scale * 2)),
|
|
179
|
+
height: Math.ceil(y + PAD_Y * scale),
|
|
173
180
|
};
|
|
174
181
|
}
|
|
175
182
|
|
|
@@ -220,12 +227,14 @@ export function paintEditMenu(
|
|
|
220
227
|
{ geometry, active = -1, colors = EDIT_MENU_COLORS, layoutOf, radius = 4 },
|
|
221
228
|
) {
|
|
222
229
|
const { width, height, rows } = geometry;
|
|
230
|
+
const s = geometry.scale ?? 1;
|
|
231
|
+
const padX = PAD_X * s;
|
|
223
232
|
|
|
224
|
-
panelPath(ctx, width, height, radius);
|
|
233
|
+
panelPath(ctx, width, height, radius * s);
|
|
225
234
|
ctx.fillStyle = colors.background;
|
|
226
235
|
ctx.fill();
|
|
227
236
|
ctx.strokeStyle = colors.border;
|
|
228
|
-
ctx.lineWidth =
|
|
237
|
+
ctx.lineWidth = s;
|
|
229
238
|
ctx.stroke();
|
|
230
239
|
|
|
231
240
|
const label = (text, color, x, row) => {
|
|
@@ -241,10 +250,10 @@ export function paintEditMenu(
|
|
|
241
250
|
if (row.separator) {
|
|
242
251
|
ctx.fillStyle = colors.separator;
|
|
243
252
|
ctx.fillRect(
|
|
244
|
-
|
|
253
|
+
padX,
|
|
245
254
|
Math.round(row.y + row.height / 2) + 0.5,
|
|
246
|
-
width -
|
|
247
|
-
|
|
255
|
+
width - padX * 2,
|
|
256
|
+
s,
|
|
248
257
|
);
|
|
249
258
|
continue;
|
|
250
259
|
}
|
|
@@ -259,14 +268,14 @@ export function paintEditMenu(
|
|
|
259
268
|
: on
|
|
260
269
|
? colors.highlightText
|
|
261
270
|
: colors.text;
|
|
262
|
-
label(row.label, color,
|
|
271
|
+
label(row.label, color, padX, row);
|
|
263
272
|
if (row.shortcut) {
|
|
264
273
|
// the shortcut column stays quieter than the label, except in the
|
|
265
274
|
// highlighted row where it has to stay legible on the accent
|
|
266
275
|
const shortcutColor = on ? colors.highlightText : colors.dim;
|
|
267
276
|
const layout = layoutOf?.(row.shortcut, shortcutColor);
|
|
268
277
|
const w = layout?.width ?? 0;
|
|
269
|
-
label(row.shortcut, shortcutColor, width -
|
|
278
|
+
label(row.shortcut, shortcutColor, width - padX - w, row);
|
|
270
279
|
}
|
|
271
280
|
}
|
|
272
281
|
}
|
package/src/events.js
CHANGED
|
@@ -733,7 +733,16 @@ export class EventManager {
|
|
|
733
733
|
* way somewhere rather than one, and React must be free to interrupt the
|
|
734
734
|
* render it started for the notch before.
|
|
735
735
|
*/
|
|
736
|
-
|
|
736
|
+
/**
|
|
737
|
+
* @param {object} native ntk's wheel event, in this window's coordinates
|
|
738
|
+
* @param {object} [over] the node the wheel happened over, when the caller
|
|
739
|
+
* already knows. A `<glarea>` owns its own X window, so the server
|
|
740
|
+
* delivers the event *there* and the surface hands it back translated
|
|
741
|
+
* (src/glnodes.js) — and a hit test would answer with the box behind it,
|
|
742
|
+
* because a window-owning child is not in its parent's paint order.
|
|
743
|
+
* Knowing beats guessing; everything after this line is the same.
|
|
744
|
+
*/
|
|
745
|
+
_onWheel(native, over = null) {
|
|
737
746
|
// The first wheel is what says this window wants smooth scrolling. It was
|
|
738
747
|
// created on core events — an XI2 selection costs four times as many
|
|
739
748
|
// bytes per *pointer move*, which a window that is never scrolled would
|
|
@@ -747,7 +756,7 @@ export class EventManager {
|
|
|
747
756
|
// outside gets, and for the same reason (`_pressOutside`).
|
|
748
757
|
if (this._dismissOutside(native)) return;
|
|
749
758
|
runWithPriority(ContinuousEventPriority, () => {
|
|
750
|
-
const target = this._hit(native);
|
|
759
|
+
const target = over ?? this._hit(native);
|
|
751
760
|
// Shift turns a vertical wheel sideways — the convention for the mouse
|
|
752
761
|
// and the touchpad that have no horizontal axis. Read off the delta
|
|
753
762
|
// rather than off the source: a plain wheel mouse on an XI2 connection
|
package/src/frame/childmain.js
CHANGED
|
@@ -233,6 +233,15 @@ export async function runFrameChild(transport, options = {}) {
|
|
|
233
233
|
return fatal('connect', err);
|
|
234
234
|
}
|
|
235
235
|
|
|
236
|
+
// A backend that presents panes over shared memory instead of a server
|
|
237
|
+
// (the Cocoa one) takes the channel itself: geometry and input in,
|
|
238
|
+
// presents out. Feature-detected — the X11 pane path has a real window
|
|
239
|
+
// and a real server and needs none of this.
|
|
240
|
+
root.app.attachPaneChannel?.({
|
|
241
|
+
send: (msg) => transport.send(msg),
|
|
242
|
+
onMessage: (cb) => transport.onMessage(cb),
|
|
243
|
+
});
|
|
244
|
+
|
|
236
245
|
let closing = false;
|
|
237
246
|
const close = async () => {
|
|
238
247
|
if (closing) return;
|