react-x11 2.2.1 → 2.3.0
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/nodes.js +107 -33
- package/src/paintcache.js +15 -4
- package/src/palette.js +14 -0
- package/src/styles.js +5 -0
- package/src/types/elements.d.ts +10 -0
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
// An ntk-window-shaped object over an NSWindow — the contract WindowNode
|
|
2
|
+
// realizes against (src/testing/mock-app.js is the reference shape; this
|
|
3
|
+
// file is that shape with real glass behind it).
|
|
4
|
+
//
|
|
5
|
+
// Units: everything crossing THIS object's boundary is device pixels, like
|
|
6
|
+
// an X window — attributes, reported width/height, event coordinates,
|
|
7
|
+
// _screenOrigin. The divide-by-scale into Cocoa points happens against the
|
|
8
|
+
// native layer and nowhere above it.
|
|
9
|
+
import { CocoaContext2D } from './context2d.js';
|
|
10
|
+
import { CocoaLayerPresenter } from './presenter.js';
|
|
11
|
+
|
|
12
|
+
let nextWindowId = 1;
|
|
13
|
+
|
|
14
|
+
export class CocoaWindow {
|
|
15
|
+
constructor(app, attributes = {}) {
|
|
16
|
+
this.app = app;
|
|
17
|
+
this._native = app._native;
|
|
18
|
+
this.attributes = attributes;
|
|
19
|
+
this.scale = app.scale;
|
|
20
|
+
this.id = nextWindowId++;
|
|
21
|
+
this.X = app.X;
|
|
22
|
+
this.destroyed = false;
|
|
23
|
+
this.mapped = false;
|
|
24
|
+
this._handlers = new Map();
|
|
25
|
+
this._surface = null;
|
|
26
|
+
this._surfaceGen = 0;
|
|
27
|
+
this._ctx = null;
|
|
28
|
+
this._dirty = false;
|
|
29
|
+
|
|
30
|
+
const s = this.scale;
|
|
31
|
+
// Snapped to whole POINTS: AppKit rounds window sizes to the point
|
|
32
|
+
// grid, so an odd device-pixel request comes back one short in the
|
|
33
|
+
// resize echo, the echo re-requests, and the backing surface churns —
|
|
34
|
+
// each swap an uninitialized canvas only the next damage rect repaints.
|
|
35
|
+
const snap = (v, fallback) =>
|
|
36
|
+
Math.max(1, Math.round(Math.max(1, Math.round(v ?? fallback)) / s) * s);
|
|
37
|
+
this.width = snap(attributes.width, 640);
|
|
38
|
+
this.height = snap(attributes.height, 480);
|
|
39
|
+
this.title = attributes.title ?? '';
|
|
40
|
+
this._popup = attributes.overrideRedirect === true;
|
|
41
|
+
|
|
42
|
+
const options = {
|
|
43
|
+
width: this.width / s,
|
|
44
|
+
height: this.height / s,
|
|
45
|
+
title: this.title,
|
|
46
|
+
kind: this._popup
|
|
47
|
+
? 'popup'
|
|
48
|
+
: attributes.decorations === false
|
|
49
|
+
? 'borderless'
|
|
50
|
+
: 'normal',
|
|
51
|
+
resizable: attributes.resizable !== false,
|
|
52
|
+
};
|
|
53
|
+
if (typeof attributes.x === 'number' && typeof attributes.y === 'number') {
|
|
54
|
+
options.x = attributes.x / s;
|
|
55
|
+
options.y = attributes.y / s;
|
|
56
|
+
}
|
|
57
|
+
// `transparent` arrives as a 32-bit visual request on X; here every
|
|
58
|
+
// window can composite, so the flag simply makes the glass clear.
|
|
59
|
+
const transparent =
|
|
60
|
+
attributes.visual !== undefined || attributes.transparent;
|
|
61
|
+
this._transparentWindow = Boolean(transparent);
|
|
62
|
+
if (transparent) options.opaque = false;
|
|
63
|
+
// The root layer's background is the "what newly exposed area shows"
|
|
64
|
+
// attribute an X window has — worth seeding on an opaque window so a
|
|
65
|
+
// resize flashes the right colour. On a transparent one it would sit
|
|
66
|
+
// OPAQUE behind the alpha the renderer paints (rounded corners went
|
|
67
|
+
// square behind it), and the honest ground there is nothing at all.
|
|
68
|
+
if (!transparent && attributes.backgroundColor !== undefined) {
|
|
69
|
+
const parsed = app._parseColor(attributes.backgroundColor);
|
|
70
|
+
if (parsed) options.backgroundColor = parsed;
|
|
71
|
+
}
|
|
72
|
+
this._h = this._native.createWindow2(options);
|
|
73
|
+
this.windowNumber = this._native.windowNumber(this._h);
|
|
74
|
+
this._layer = this._native.windowRootLayer(this._h);
|
|
75
|
+
this._refreshOrigin();
|
|
76
|
+
if (attributes.sizeHints) this.setSizeHints(attributes.sizeHints);
|
|
77
|
+
|
|
78
|
+
// The retained layer presenter (docs/macos.md Tier L), behind
|
|
79
|
+
// REACT_X11_COCOA_PRESENTER=layers while the surface path is the
|
|
80
|
+
// measured default. Its two hooks exist only in this mode, so the
|
|
81
|
+
// feature detection in nodes.js keeps the surface path byte-identical;
|
|
82
|
+
// the scroll blit is shadowed off because a layer frame has no backing
|
|
83
|
+
// bitmap to blit.
|
|
84
|
+
if (app._presenterMode === 'layers') {
|
|
85
|
+
this._presenter = new CocoaLayerPresenter(this);
|
|
86
|
+
this.presentFrame = (windowNode) => this._presenter.frame(windowNode);
|
|
87
|
+
this.noteInvalidate = (damage, layoutChanged) =>
|
|
88
|
+
this._presenter.noteInvalidate(damage, layoutChanged);
|
|
89
|
+
this.scrollRegion = null;
|
|
90
|
+
}
|
|
91
|
+
app._registerWindow(this);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// --- events --------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
on(name, fn) {
|
|
97
|
+
let list = this._handlers.get(name);
|
|
98
|
+
if (!list) this._handlers.set(name, (list = []));
|
|
99
|
+
list.push(fn);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
emit(name, ev) {
|
|
103
|
+
const list = this._handlers.get(name);
|
|
104
|
+
if (!list) return;
|
|
105
|
+
for (const fn of [...list]) fn(ev);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// --- geometry ------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
_refreshOrigin() {
|
|
111
|
+
const f = this._native.getWindowFrame(this._h);
|
|
112
|
+
const s = this.scale;
|
|
113
|
+
this.x = Math.round(f.x * s);
|
|
114
|
+
this.y = Math.round(f.y * s);
|
|
115
|
+
this._screenOrigin = { x: this.x, y: this.y };
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Native geometry changed (delegate event, points). */
|
|
119
|
+
_nativeResized(points) {
|
|
120
|
+
const s = this.scale;
|
|
121
|
+
this.width = Math.max(1, Math.round(points.width * s));
|
|
122
|
+
this.height = Math.max(1, Math.round(points.height * s));
|
|
123
|
+
this.x = Math.round(points.x * s);
|
|
124
|
+
this.y = Math.round(points.y * s);
|
|
125
|
+
this._screenOrigin = { x: this.x, y: this.y };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
resize(width, height) {
|
|
129
|
+
const s = this.scale;
|
|
130
|
+
this.width = Math.max(1, Math.round(Math.round(width) / s) * s);
|
|
131
|
+
this.height = Math.max(1, Math.round(Math.round(height) / s) * s);
|
|
132
|
+
this._native.setWindowFrame(
|
|
133
|
+
this._h,
|
|
134
|
+
null,
|
|
135
|
+
null,
|
|
136
|
+
this.width / s,
|
|
137
|
+
this.height / s,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
move(x, y) {
|
|
142
|
+
const s = this.scale;
|
|
143
|
+
this.x = Math.round(x);
|
|
144
|
+
this.y = Math.round(y);
|
|
145
|
+
this._native.setWindowFrame(this._h, x / s, y / s, null, null);
|
|
146
|
+
this._screenOrigin = { x: this.x, y: this.y };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// --- lifecycle -----------------------------------------------------------
|
|
150
|
+
|
|
151
|
+
map() {
|
|
152
|
+
if (this.destroyed) return;
|
|
153
|
+
this.mapped = true;
|
|
154
|
+
// A popup must not take the keyboard from its owner; a toplevel's first
|
|
155
|
+
// map is the app coming up and takes it.
|
|
156
|
+
this._native.showWindow(this._h, !this._popup);
|
|
157
|
+
this._refreshOrigin();
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
unmap() {
|
|
161
|
+
if (this.destroyed) return;
|
|
162
|
+
this.mapped = false;
|
|
163
|
+
this._native.hideWindow(this._h);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
destroy() {
|
|
167
|
+
if (this.destroyed) return;
|
|
168
|
+
this.destroyed = true;
|
|
169
|
+
this.mapped = false;
|
|
170
|
+
this.app._unregisterWindow(this);
|
|
171
|
+
this._native.destroyWindow2(this._h);
|
|
172
|
+
this._surface = null;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// --- window-manager-ish surface (feature-detected by nodes.js) -----------
|
|
176
|
+
|
|
177
|
+
setTitle(title) {
|
|
178
|
+
this.title = title;
|
|
179
|
+
this._native.setWindowTitle(this._h, String(title ?? ''));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
setSizeHints(hints = {}) {
|
|
183
|
+
const s = this.scale;
|
|
184
|
+
const box = {};
|
|
185
|
+
if (typeof hints.minWidth === 'number') box.minWidth = hints.minWidth / s;
|
|
186
|
+
if (typeof hints.minHeight === 'number')
|
|
187
|
+
box.minHeight = hints.minHeight / s;
|
|
188
|
+
if (typeof hints.maxWidth === 'number') box.maxWidth = hints.maxWidth / s;
|
|
189
|
+
if (typeof hints.maxHeight === 'number')
|
|
190
|
+
box.maxHeight = hints.maxHeight / s;
|
|
191
|
+
if (Object.keys(box).length) this._native.setWindowMinMax(this._h, box);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
setClass() {}
|
|
195
|
+
|
|
196
|
+
setWindowType() {}
|
|
197
|
+
|
|
198
|
+
setActions() {}
|
|
199
|
+
|
|
200
|
+
setTransientFor() {
|
|
201
|
+
// addChildWindow attachment comes with the layer presenter phase; a
|
|
202
|
+
// managed dialog already floats via its own window today.
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
setCursor(name) {
|
|
206
|
+
this._native.setCursor(String(name ?? 'default'));
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
grabPointer(options, cb) {
|
|
210
|
+
this.app._grabWindow = this;
|
|
211
|
+
cb?.(null, 0);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
ungrabPointer() {
|
|
215
|
+
if (this.app._grabWindow === this) this.app._grabWindow = null;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
selectXI2() {
|
|
219
|
+
// AppKit's precise scroll deltas are already flowing; nothing to select.
|
|
220
|
+
return Promise.resolve(true);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// --- drawing -------------------------------------------------------------
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* The backing store is a two-buffer IOSurface swapchain: painters draw
|
|
227
|
+
* into the back buffer's CG bitmap, and presenting is `layer.contents =
|
|
228
|
+
* iosurface` — zero-copy, where the plain-surface path paid a
|
|
229
|
+
* window-sized CGImage copy per dirty frame (12ms at 900x700@2x — the
|
|
230
|
+
* presenter bench's whole surface-vs-layers gap on bounded damage).
|
|
231
|
+
* After a flip the new back buffer is one frame stale, so present copies
|
|
232
|
+
* the just-shown frame's damage across — a damage-sized memcpy replacing
|
|
233
|
+
* a window-sized upload. Falls back to the single plain surface where
|
|
234
|
+
* IOSurface creation fails.
|
|
235
|
+
*/
|
|
236
|
+
_ensureSurface() {
|
|
237
|
+
const w = this.width;
|
|
238
|
+
const h = this.height;
|
|
239
|
+
if (
|
|
240
|
+
!this._surface ||
|
|
241
|
+
this._surfaceSize?.width !== w ||
|
|
242
|
+
this._surfaceSize?.height !== h
|
|
243
|
+
) {
|
|
244
|
+
const hadSurface = Boolean(this._surface);
|
|
245
|
+
this._chain = null;
|
|
246
|
+
try {
|
|
247
|
+
const a = this._native.createSurfaceIOSurface(w, h, this.scale);
|
|
248
|
+
const b = this._native.createSurfaceIOSurface(w, h, this.scale);
|
|
249
|
+
this._chain = { back: a, front: b };
|
|
250
|
+
this._native.surfaceLock(a.handle);
|
|
251
|
+
this._native.ctxClearRect(a.handle, 0, 0, w, h);
|
|
252
|
+
this._native.ctxClearRect(b.handle, 0, 0, w, h);
|
|
253
|
+
this._surface = a.handle;
|
|
254
|
+
} catch {
|
|
255
|
+
this._surface = this._native.createSurface(w, h, this.scale);
|
|
256
|
+
this._native.ctxClearRect(this._surface, 0, 0, w, h);
|
|
257
|
+
}
|
|
258
|
+
this._surfaceSize = { width: w, height: h };
|
|
259
|
+
this._surfaceGen++;
|
|
260
|
+
this._flushDamage = 'full';
|
|
261
|
+
// A replaced backing surface holds nothing: whatever bounded damage
|
|
262
|
+
// this frame carries, everything else on it would be garbage. Ask for
|
|
263
|
+
// the full frame — one extra repaint per real resize, correctness for
|
|
264
|
+
// every pixel outside the damage rect.
|
|
265
|
+
if (hadSurface) {
|
|
266
|
+
queueMicrotask(() => {
|
|
267
|
+
const node = this._reactX11Node;
|
|
268
|
+
if (node && !node.destroyed) node.invalidate(true, null, 'resize');
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return this._surface;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/**
|
|
276
|
+
* The per-flush painted rects (nodes.js's swapchain seam), accumulated
|
|
277
|
+
* until the next present: they are what the flip's catch-up copy covers.
|
|
278
|
+
* `'full'`/null collapse the set — one full copy beats bookkeeping.
|
|
279
|
+
*/
|
|
280
|
+
noteFrameDamage(rects) {
|
|
281
|
+
if (this._presenter) return;
|
|
282
|
+
if (this._flushDamage === 'full') return;
|
|
283
|
+
if (!rects) {
|
|
284
|
+
this._flushDamage = 'full';
|
|
285
|
+
return;
|
|
286
|
+
}
|
|
287
|
+
(this._flushDamage ??= []).push(...rects);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
getContext() {
|
|
291
|
+
if (!this._ctx) {
|
|
292
|
+
this._ctx = new CocoaContext2D(
|
|
293
|
+
this._native,
|
|
294
|
+
() => this._ensureSurface(),
|
|
295
|
+
() => {
|
|
296
|
+
this._ensureSurface();
|
|
297
|
+
return this._surfaceGen;
|
|
298
|
+
},
|
|
299
|
+
);
|
|
300
|
+
this._ctx._fonts = this.app.fonts;
|
|
301
|
+
this._ctx._onDirty = () => {
|
|
302
|
+
this._dirty = true;
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
return this._ctx;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/** The scroll-blit fast path: move pixels inside the backing surface,
|
|
309
|
+
* with ntk Window.scrollRegion's contract — the shift happens WITHIN the
|
|
310
|
+
* rect, and a delta that leaves no surviving band reports false so the
|
|
311
|
+
* caller falls back to the plain repaint. */
|
|
312
|
+
scrollRegion(rect, dx, dy) {
|
|
313
|
+
if (!this._surface) return false;
|
|
314
|
+
if (!Number.isInteger(dx) || !Number.isInteger(dy)) return false;
|
|
315
|
+
const moved = this._native.scrollSurface(
|
|
316
|
+
this._surface,
|
|
317
|
+
Math.round(rect.x),
|
|
318
|
+
Math.round(rect.y),
|
|
319
|
+
Math.round(rect.width),
|
|
320
|
+
Math.round(rect.height),
|
|
321
|
+
dx,
|
|
322
|
+
dy,
|
|
323
|
+
);
|
|
324
|
+
if (moved) this._dirty = true;
|
|
325
|
+
return Boolean(moved);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
frameInFlight() {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
requestAnimationFrame(cb) {
|
|
333
|
+
return this.app._requestFrame(cb);
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/** Push the backing surface at the WindowServer, if anything drew. */
|
|
337
|
+
present() {
|
|
338
|
+
if (this._presenter) return; // layers upload as they sync
|
|
339
|
+
if (!this._dirty || !this._surface || this.destroyed) return;
|
|
340
|
+
this._dirty = false;
|
|
341
|
+
if (this._chain) {
|
|
342
|
+
const shown = this._chain.back;
|
|
343
|
+
this._native.surfaceUnlock(shown.handle);
|
|
344
|
+
this._native.setLayerContentsIOSurface(this._layer, shown.iosurfaceId);
|
|
345
|
+
this._chain.back = this._chain.front;
|
|
346
|
+
this._chain.front = shown;
|
|
347
|
+
this._surface = this._chain.back.handle;
|
|
348
|
+
// a different native surface owns the graphics state now — the
|
|
349
|
+
// context re-syncs its sticky state off the generation
|
|
350
|
+
this._surfaceGen++;
|
|
351
|
+
this._native.surfaceLock(this._surface);
|
|
352
|
+
const damage = this._flushDamage;
|
|
353
|
+
this._flushDamage = null;
|
|
354
|
+
this._native.copySurfaceRegion(
|
|
355
|
+
shown.handle,
|
|
356
|
+
this._surface,
|
|
357
|
+
damage === 'full' || !damage
|
|
358
|
+
? null
|
|
359
|
+
: damage.flatMap((r) => [
|
|
360
|
+
Math.floor(r.x),
|
|
361
|
+
Math.floor(r.y),
|
|
362
|
+
Math.ceil(r.width) + 1,
|
|
363
|
+
Math.ceil(r.height) + 1,
|
|
364
|
+
]),
|
|
365
|
+
);
|
|
366
|
+
if (this._transparentWindow) this.app._shadowStale.add(this);
|
|
367
|
+
return;
|
|
368
|
+
}
|
|
369
|
+
this._native.surfaceToLayer(this._surface, this._layer);
|
|
370
|
+
// AppKit derives a transparent window's shadow from the content's
|
|
371
|
+
// opaque shape and does not recompute it on repaints — a popup whose
|
|
372
|
+
// card lands a frame after the map keeps the full-frame square AppKit
|
|
373
|
+
// guessed first. Recompute — but only once this present's transaction
|
|
374
|
+
// has actually flushed to the render server, or the recompute reads
|
|
375
|
+
// the frame BEFORE this one and keeps the square rim for menus that
|
|
376
|
+
// paint once and are only hovered after.
|
|
377
|
+
if (this._transparentWindow) this.app._shadowStale.add(this);
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
snapshot(path) {
|
|
381
|
+
return this._native.snapshotWindow(this._h, path);
|
|
382
|
+
}
|
|
383
|
+
}
|
package/src/components/Button.js
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
// build-step-free for consumers.
|
|
4
4
|
|
|
5
5
|
import React from 'react';
|
|
6
|
+
import { useAppOrNull } from '../appcontext.js';
|
|
7
|
+
import {
|
|
8
|
+
ABS_FILL,
|
|
9
|
+
Bezel,
|
|
10
|
+
bezelNatural,
|
|
11
|
+
pressWash,
|
|
12
|
+
useNativeControls,
|
|
13
|
+
} from './native.js';
|
|
6
14
|
import { labelContent, useControl, useTheme } from './theme.js';
|
|
7
15
|
|
|
8
16
|
const h = React.createElement;
|
|
@@ -46,6 +54,7 @@ export function Button({
|
|
|
46
54
|
variant = 'solid',
|
|
47
55
|
size = 'medium',
|
|
48
56
|
disabled = false,
|
|
57
|
+
native,
|
|
49
58
|
style,
|
|
50
59
|
...boxProps
|
|
51
60
|
}) {
|
|
@@ -61,12 +70,71 @@ export function Button({
|
|
|
61
70
|
throw new Error(`<Button size="${size}">: one of ${SIZES.join(', ')}`);
|
|
62
71
|
}
|
|
63
72
|
const theme = useTheme();
|
|
73
|
+
const app = useAppOrNull();
|
|
74
|
+
const nativeControls = useNativeControls(native);
|
|
64
75
|
const { props, style: controlStyle } = useControl(disabled, onPress, {
|
|
65
76
|
styled: true,
|
|
66
77
|
});
|
|
67
78
|
const solid = variant === 'solid';
|
|
68
79
|
const ghost = variant === 'ghost';
|
|
69
80
|
const small = size === 'small';
|
|
81
|
+
|
|
82
|
+
// Only the solid variant has a native counterpart: outline and ghost are
|
|
83
|
+
// deliberately chrome-less designs AppKit has no bezel for, so they keep
|
|
84
|
+
// the drawn rendering under every policy.
|
|
85
|
+
if (nativeControls && solid) {
|
|
86
|
+
const controlSize = small ? 'small' : 'regular';
|
|
87
|
+
const nat = bezelNatural(app, 'push', controlSize);
|
|
88
|
+
return h(
|
|
89
|
+
'box',
|
|
90
|
+
{
|
|
91
|
+
theme,
|
|
92
|
+
role: 'button',
|
|
93
|
+
...props,
|
|
94
|
+
...boxProps,
|
|
95
|
+
style: [
|
|
96
|
+
controlStyle,
|
|
97
|
+
{
|
|
98
|
+
flexDirection: 'row',
|
|
99
|
+
alignItems: 'center',
|
|
100
|
+
justifyContent: 'center',
|
|
101
|
+
gap: small ? 6 : 8,
|
|
102
|
+
// AppKit's metrics, not the palette's: a native bezel is
|
|
103
|
+
// designed at its own height, and stretching it is what this
|
|
104
|
+
// mode exists to avoid. Width still follows the label.
|
|
105
|
+
height: nat.height,
|
|
106
|
+
paddingLeft: small ? 10 : 14,
|
|
107
|
+
paddingRight: small ? 10 : 14,
|
|
108
|
+
color: disabled
|
|
109
|
+
? theme.textMuted
|
|
110
|
+
: primary
|
|
111
|
+
? theme.accentText
|
|
112
|
+
: theme.text,
|
|
113
|
+
},
|
|
114
|
+
style,
|
|
115
|
+
],
|
|
116
|
+
},
|
|
117
|
+
h(Bezel, {
|
|
118
|
+
kind: 'push',
|
|
119
|
+
controlSize,
|
|
120
|
+
enabled: !disabled,
|
|
121
|
+
// the Return-key accent fill is AppKit's own "default button"
|
|
122
|
+
isDefault: primary && !disabled,
|
|
123
|
+
style: ABS_FILL,
|
|
124
|
+
}),
|
|
125
|
+
labelContent(children ?? label),
|
|
126
|
+
// The press answer. Last child on purpose: `:active` marks the
|
|
127
|
+
// pressed node and its ancestors, and the topmost child is what the
|
|
128
|
+
// press lands on. No hover tint — AppKit buttons have none.
|
|
129
|
+
h('box', {
|
|
130
|
+
style: [
|
|
131
|
+
ABS_FILL,
|
|
132
|
+
{ borderRadius: small ? 5 : 6 },
|
|
133
|
+
!disabled && { ':active': { backgroundColor: pressWash(theme) } },
|
|
134
|
+
],
|
|
135
|
+
}),
|
|
136
|
+
);
|
|
137
|
+
}
|
|
70
138
|
const background = !solid
|
|
71
139
|
? // `transparent` rather than the ground's colour: an outline or ghost
|
|
72
140
|
// button sits on whatever it sits on — a toolbar, a card, a table row —
|
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// build-step-free for consumers.
|
|
4
4
|
|
|
5
5
|
import React from 'react';
|
|
6
|
+
import { useAppOrNull } from '../appcontext.js';
|
|
6
7
|
import { changeEvent } from './change.js';
|
|
7
8
|
import { Icon } from './Icon.js';
|
|
9
|
+
import { Bezel, bezelNatural, useNativeControls } from './native.js';
|
|
8
10
|
import { focusRingStyle, labelContent, useControl, useTheme } from './theme.js';
|
|
9
11
|
|
|
10
12
|
const h = React.createElement;
|
|
@@ -32,10 +34,13 @@ export function Checkbox({
|
|
|
32
34
|
onChange,
|
|
33
35
|
name,
|
|
34
36
|
disabled = false,
|
|
37
|
+
native,
|
|
35
38
|
style,
|
|
36
39
|
...boxProps
|
|
37
40
|
}) {
|
|
38
41
|
const theme = useTheme();
|
|
42
|
+
const app = useAppOrNull();
|
|
43
|
+
const nativeControls = useNativeControls(native);
|
|
39
44
|
const {
|
|
40
45
|
hover,
|
|
41
46
|
focused,
|
|
@@ -45,6 +50,44 @@ export function Checkbox({
|
|
|
45
50
|
} = useControl(disabled, () =>
|
|
46
51
|
onChange?.(changeEvent('checkbox', name, !checked)),
|
|
47
52
|
);
|
|
53
|
+
|
|
54
|
+
// Native mode swaps only the well: AppKit's checkbox bezel at its natural
|
|
55
|
+
// size, driven by the same React press state the drawn well uses — the
|
|
56
|
+
// pressed bezel is a re-keyed image, so the press still lands on the
|
|
57
|
+
// press frame. Focus keeps the shared ring (the one focus look the whole
|
|
58
|
+
// app has), and hover has no tint because AppKit checkboxes have none.
|
|
59
|
+
if (nativeControls) {
|
|
60
|
+
const nat = bezelNatural(app, 'checkbox');
|
|
61
|
+
return h(
|
|
62
|
+
'box',
|
|
63
|
+
{
|
|
64
|
+
theme,
|
|
65
|
+
role: 'checkbox',
|
|
66
|
+
'aria-checked': checked,
|
|
67
|
+
...props,
|
|
68
|
+
...boxProps,
|
|
69
|
+
style: [
|
|
70
|
+
controlStyle,
|
|
71
|
+
{ flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
72
|
+
style,
|
|
73
|
+
],
|
|
74
|
+
},
|
|
75
|
+
h(Bezel, {
|
|
76
|
+
kind: 'checkbox',
|
|
77
|
+
state: checked ? 1 : 0,
|
|
78
|
+
pressed,
|
|
79
|
+
enabled: !disabled,
|
|
80
|
+
style: {
|
|
81
|
+
width: nat.width,
|
|
82
|
+
height: nat.height,
|
|
83
|
+
...focusRingStyle(theme, focused),
|
|
84
|
+
},
|
|
85
|
+
}),
|
|
86
|
+
labelContent(children ?? label, {
|
|
87
|
+
color: disabled ? theme.textMuted : theme.text,
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
48
91
|
const fill = disabled
|
|
49
92
|
? theme.textMuted
|
|
50
93
|
: pressed
|
package/src/components/Radio.js
CHANGED
|
@@ -3,7 +3,9 @@
|
|
|
3
3
|
// build-step-free for consumers.
|
|
4
4
|
|
|
5
5
|
import React, { useContext, useEffect, useMemo, useRef } from 'react';
|
|
6
|
+
import { useAppOrNull } from '../appcontext.js';
|
|
6
7
|
import { changeEvent } from './change.js';
|
|
8
|
+
import { Bezel, bezelNatural, useNativeControls } from './native.js';
|
|
7
9
|
import { focusRingStyle, labelContent, useControl, useTheme } from './theme.js';
|
|
8
10
|
import { XK_DOWN, XK_LEFT, XK_RIGHT, XK_UP } from './keys.js';
|
|
9
11
|
|
|
@@ -52,8 +54,10 @@ export function RadioGroup({
|
|
|
52
54
|
);
|
|
53
55
|
}
|
|
54
56
|
|
|
55
|
-
export function Radio({ value, children, label, disabled = false }) {
|
|
57
|
+
export function Radio({ value, children, label, disabled = false, native }) {
|
|
56
58
|
const theme = useTheme();
|
|
59
|
+
const app = useAppOrNull();
|
|
60
|
+
const nativeControls = useNativeControls(native);
|
|
57
61
|
const group = useContext(RadioGroupContext);
|
|
58
62
|
if (!group) {
|
|
59
63
|
throw new Error('react-x11: <Radio> must be inside a <RadioGroup>');
|
|
@@ -79,6 +83,38 @@ export function Radio({ value, children, label, disabled = false }) {
|
|
|
79
83
|
else if (ev.keysym === XK_UP || ev.keysym === XK_LEFT) group.move(-1);
|
|
80
84
|
};
|
|
81
85
|
}
|
|
86
|
+
|
|
87
|
+
// The well swap Checkbox documents, with the radio bezel.
|
|
88
|
+
if (nativeControls) {
|
|
89
|
+
const nat = bezelNatural(app, 'radio');
|
|
90
|
+
return h(
|
|
91
|
+
'box',
|
|
92
|
+
{
|
|
93
|
+
theme,
|
|
94
|
+
role: 'radio',
|
|
95
|
+
'aria-checked': selected,
|
|
96
|
+
...props,
|
|
97
|
+
style: [
|
|
98
|
+
controlStyle,
|
|
99
|
+
{ flexDirection: 'row', alignItems: 'center', gap: 8 },
|
|
100
|
+
],
|
|
101
|
+
},
|
|
102
|
+
h(Bezel, {
|
|
103
|
+
kind: 'radio',
|
|
104
|
+
state: selected ? 1 : 0,
|
|
105
|
+
pressed,
|
|
106
|
+
enabled: !disabled,
|
|
107
|
+
style: {
|
|
108
|
+
width: nat.width,
|
|
109
|
+
height: nat.height,
|
|
110
|
+
...focusRingStyle(theme, focused),
|
|
111
|
+
},
|
|
112
|
+
}),
|
|
113
|
+
labelContent(children ?? label, {
|
|
114
|
+
color: disabled ? theme.textMuted : theme.text,
|
|
115
|
+
}),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
82
118
|
// The dot only appears on the release, so the well answers the press
|
|
83
119
|
// itself — see Checkbox, both for the three-look treatment and for why
|
|
84
120
|
// this is React state rather than an `:active` block.
|