basalt-appkit 0.0.0-rc5 → 0.1.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/native/CMakeLists.txt +16 -2
- package/native/appkit/AppKitAppWindow.h +24 -0
- package/native/appkit/AppKitImageLoader.h +48 -1
- package/native/appkit/AppKitImageLoader.mm +228 -10
- package/native/appkit/AppKitMenuBar.mm +58 -0
- package/native/appkit/AppKitMountingManager.h +34 -0
- package/native/appkit/AppKitMountingManager.mm +576 -9
- package/native/appkit/AppKitScrollView.h +48 -0
- package/native/appkit/AppKitScrollView.mm +210 -0
- package/native/appkit/AppKitTextInput.h +38 -1
- package/native/appkit/AppKitTextInput.mm +235 -8
- package/native/appkit/AppKitTextPeer.h +60 -0
- package/native/appkit/AppKitTextPeer.mm +307 -0
- package/native/appkit/AppKitTouchDispatcher.h +56 -0
- package/native/appkit/AppKitTouchDispatcher.mm +140 -0
- package/native/appkit/AppKitWindowControl.mm +38 -3
- package/native/appkit/ComponentRegistryAppKit.mm +20 -0
- package/native/appkit/CoreTextLayout.h +64 -2
- package/native/appkit/CoreTextLayout.mm +431 -71
- package/native/appkit/CoreTextLayoutManager.mm +57 -6
- package/native/appkit/FontRegistryCoreText.mm +6 -5
- package/native/appkit/PlatformServicesAppKit.mm +101 -20
- package/native/appkit/RnAppKitView.h +492 -0
- package/native/appkit/RnAppKitView.mm +2266 -23
- package/native/appkit/RnTextLayout.h +106 -0
- package/native/appkit/RnTextLayout.mm +321 -2
- package/native/appkit/main_appkit.mm +174 -3
- package/native/tests/test_appkit_accessibility.mm +289 -0
- package/native/tests/test_appkit_borders.mm +975 -0
- package/native/tests/test_appkit_fonts.mm +203 -0
- package/native/tests/test_appkit_image.mm +392 -17
- package/native/tests/test_appkit_input.mm +209 -0
- package/native/tests/test_appkit_mounting.mm +360 -0
- package/native/tests/test_appkit_scrollbar.mm +125 -0
- package/native/tests/test_appkit_scrollview.mm +371 -0
- package/native/tests/test_appkit_selection.mm +349 -0
- package/native/tests/test_appkit_services.mm +51 -1
- package/native/tests/test_appkit_text.mm +1628 -0
- package/native/tests/test_appkit_textinput.mm +819 -0
- package/native/tests/test_appkit_titlebar.mm +43 -0
- package/native/tests/test_appkit_view.mm +609 -0
- package/package.json +4 -3
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
|
|
3
3
|
#include "ControlMetrics.h"
|
|
4
4
|
#include "Backface.h"
|
|
5
|
+
#include "ImageAnimation.h"
|
|
5
6
|
#include "FocusRing.h"
|
|
6
7
|
#include "ScrollIndicator.h"
|
|
8
|
+
#include "ShadowShape.h"
|
|
7
9
|
|
|
8
10
|
#include <vector>
|
|
9
11
|
|
|
@@ -11,10 +13,12 @@
|
|
|
11
13
|
|
|
12
14
|
#include "PointerButtons.h"
|
|
13
15
|
|
|
16
|
+
#include <algorithm>
|
|
14
17
|
#include <cmath>
|
|
15
18
|
|
|
16
19
|
#import "RnTextLayout.h"
|
|
17
20
|
|
|
21
|
+
#import <CoreImage/CoreImage.h>
|
|
18
22
|
#import <QuartzCore/QuartzCore.h>
|
|
19
23
|
|
|
20
24
|
// Topmost first: AppKit's subviews array is back to front, and a hit test wants
|
|
@@ -58,7 +62,10 @@ RnAppKitView *RnAppKitHitTest(RnAppKitView *root, CGFloat x, CGFloat y) {
|
|
|
58
62
|
const CGFloat bx = x + bounds.origin.x;
|
|
59
63
|
const CGFloat by = y + bounds.origin.y;
|
|
60
64
|
|
|
61
|
-
|
|
65
|
+
// `hitSlop` grows the box this test accepts, and only this test: the view is
|
|
66
|
+
// still drawn in its own frame. Children are reached through their own call,
|
|
67
|
+
// so each view's slop is applied to its own box on the way down.
|
|
68
|
+
if (!NSPointInRect(NSMakePoint(bx, by), [root rnHitArea])) {
|
|
62
69
|
return nil;
|
|
63
70
|
}
|
|
64
71
|
|
|
@@ -156,6 +163,209 @@ static CGPathRef RnAppKitCreateRoundedPath(CGRect rect, const CGFloat radii[8])
|
|
|
156
163
|
return path;
|
|
157
164
|
}
|
|
158
165
|
|
|
166
|
+
// One gradient as the view keeps it: the two points and its own stops. A
|
|
167
|
+
// transition hint turns three stops into eleven, so the lists are different
|
|
168
|
+
// lengths for no reason the caller controls, and each gradient owns its own.
|
|
169
|
+
struct RnAppKitGradientRecord {
|
|
170
|
+
RnAppKitGradientKind kind;
|
|
171
|
+
// Linear: the ends of the gradient line. Radial: the centre and the radii.
|
|
172
|
+
CGPoint start;
|
|
173
|
+
CGPoint end;
|
|
174
|
+
CGPoint center;
|
|
175
|
+
CGFloat radiusX;
|
|
176
|
+
CGFloat radiusY;
|
|
177
|
+
// Where the image goes and the tile it repeats in. See RnAppKitGradient.
|
|
178
|
+
CGRect area;
|
|
179
|
+
CGRect tile;
|
|
180
|
+
bool repeats;
|
|
181
|
+
std::vector<RnAppKitGradientStop> stops;
|
|
182
|
+
};
|
|
183
|
+
|
|
184
|
+
// The eight radii of a shadow's own box, each grown by the spread.
|
|
185
|
+
//
|
|
186
|
+
// The curve is `core/ShadowShape.h`'s, shared with the Win32 host, which builds
|
|
187
|
+
// a Direct2D geometry where this builds a CGPath and needs the same arithmetic.
|
|
188
|
+
// It lived here first; the comment about what the curve is and why it is not an
|
|
189
|
+
// addition moved with it.
|
|
190
|
+
static void RnAppKitSpreadRadii(const CGFloat radii[8], CGFloat spread, CGFloat out[8]) {
|
|
191
|
+
for (int i = 0; i < 8; i++) {
|
|
192
|
+
out[i] = static_cast<CGFloat>(basalt::spreadRadius(radii[i], spread));
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// The same path, walked the other way, so that adding it to another one punches
|
|
197
|
+
// a hole rather than filling it: `shadowPath` has no fill rule and winds
|
|
198
|
+
// non-zero. NSBezierPath has reversal built in, and hand-rolling it means
|
|
199
|
+
// reversing four elliptical corners in the right order, which is exactly the
|
|
200
|
+
// kind of code that is wrong in one corner only.
|
|
201
|
+
static CGPathRef RnAppKitCreateReversedPath(CGPathRef path) {
|
|
202
|
+
NSBezierPath *bezier = [NSBezierPath bezierPathWithCGPath:path];
|
|
203
|
+
return CGPathCreateCopy([bezier bezierPathByReversingPath].CGPath);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// A shadow cast outward by the view's box.
|
|
207
|
+
//
|
|
208
|
+
// The offset and the spread are baked into the path rather than set as
|
|
209
|
+
// `shadowOffset`, which is what keeps a positive dy pointing down in this
|
|
210
|
+
// flipped view, and the mask cuts the box itself out so the shadow is only ever
|
|
211
|
+
// outside it -- a shadow painted under a translucent background would otherwise
|
|
212
|
+
// show through it, which CSS does not do.
|
|
213
|
+
// A `dropShadow()` beyond the first, as a layer of its own.
|
|
214
|
+
//
|
|
215
|
+
// A CALayer carries one shadow, so a list of them needs a layer each. The first
|
|
216
|
+
// stays on the view's own layer, where it is cast from the content's *alpha* --
|
|
217
|
+
// which is what makes a drop shadow a drop shadow. The rest are cast from the
|
|
218
|
+
// view's box through a `shadowPath`, which is the one deviation here and is
|
|
219
|
+
// exact for every opaque view: a rounded card with two shadows is the case this
|
|
220
|
+
// exists for. backlog/platform-macos.md records the difference, which shows
|
|
221
|
+
// only on a view whose content has holes in it.
|
|
222
|
+
//
|
|
223
|
+
// CSS applies a filter list left to right, each function working on what the
|
|
224
|
+
// one before produced, so the second shadow is a shadow of the content *and*
|
|
225
|
+
// the first shadow. These are drawn from the same box instead, which is the
|
|
226
|
+
// same approximation and the same reason.
|
|
227
|
+
static CALayer *RnAppKitFilterShadowLayer(const RnAppKitFilterShadow &shadow,
|
|
228
|
+
const CGFloat radii[8],
|
|
229
|
+
CGSize size) {
|
|
230
|
+
CALayer *layer = [CALayer layer];
|
|
231
|
+
layer.frame = CGRectMake(0, 0, size.width, size.height);
|
|
232
|
+
// The same conversions the view's own layer uses in -rnApplyFilterShadow:,
|
|
233
|
+
// so the second shadow of a pair is drawn by the same arithmetic as the
|
|
234
|
+
// first: the standard deviation crosses unchanged, the colour's alpha is the
|
|
235
|
+
// layer's opacity, and dy is turned over because the layer is not flipped
|
|
236
|
+
// where the view is.
|
|
237
|
+
layer.shadowRadius = shadow.standardDeviation;
|
|
238
|
+
layer.shadowOpacity = (float)shadow.color[3];
|
|
239
|
+
layer.shadowOffset = CGSizeMake(shadow.dx, -shadow.dy);
|
|
240
|
+
|
|
241
|
+
CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
|
242
|
+
const CGFloat opaque[4] = {shadow.color[0], shadow.color[1], shadow.color[2], 1};
|
|
243
|
+
CGColorRef color = CGColorCreate(space, opaque);
|
|
244
|
+
layer.shadowColor = color;
|
|
245
|
+
CGColorRelease(color);
|
|
246
|
+
CGColorSpaceRelease(space);
|
|
247
|
+
|
|
248
|
+
CGPathRef shadowPath = RnAppKitCreateRoundedPath(layer.bounds, radii);
|
|
249
|
+
layer.shadowPath = shadowPath;
|
|
250
|
+
CGPathRelease(shadowPath);
|
|
251
|
+
|
|
252
|
+
// Everything outside the view's own box, the way the outset box shadows are
|
|
253
|
+
// masked: without it the shadow is drawn under the content as well, which
|
|
254
|
+
// darkens a translucent view from inside.
|
|
255
|
+
CAShapeLayer *mask = [CAShapeLayer layer];
|
|
256
|
+
mask.fillRule = kCAFillRuleEvenOdd;
|
|
257
|
+
CGMutablePathRef maskPath = CGPathCreateMutable();
|
|
258
|
+
CGPathRef viewPath = RnAppKitCreateRoundedPath(layer.bounds, radii);
|
|
259
|
+
const CGFloat reach = shadow.standardDeviation * 3 + std::abs(shadow.dx) + std::abs(shadow.dy);
|
|
260
|
+
CGPathAddRect(maskPath, nullptr, CGRectInset(layer.bounds, -reach - 1, -reach - 1));
|
|
261
|
+
CGPathAddPath(maskPath, nullptr, viewPath);
|
|
262
|
+
CGPathRelease(viewPath);
|
|
263
|
+
mask.path = maskPath;
|
|
264
|
+
CGPathRelease(maskPath);
|
|
265
|
+
layer.mask = mask;
|
|
266
|
+
return layer;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
static CALayer *RnAppKitOutsetShadowLayer(const RnAppKitBoxShadow &shadow,
|
|
270
|
+
const CGFloat radii[8],
|
|
271
|
+
CGSize size) {
|
|
272
|
+
CALayer *layer = [CALayer layer];
|
|
273
|
+
layer.frame = CGRectMake(0, 0, size.width, size.height);
|
|
274
|
+
layer.shadowOffset = CGSizeZero;
|
|
275
|
+
layer.shadowOpacity = 1;
|
|
276
|
+
// Half the radius, which is the same conversion the image blur uses and the
|
|
277
|
+
// same one React Native's iOS half settled on: CSS's blur radius is twice the
|
|
278
|
+
// gaussian's sigma, and CALayer's shadowRadius is the sigma.
|
|
279
|
+
layer.shadowRadius = shadow.blur > 0 ? shadow.blur / 2 : 0;
|
|
280
|
+
|
|
281
|
+
CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
|
282
|
+
CGColorRef color = CGColorCreate(space, shadow.color);
|
|
283
|
+
layer.shadowColor = color;
|
|
284
|
+
CGColorRelease(color);
|
|
285
|
+
CGColorSpaceRelease(space);
|
|
286
|
+
|
|
287
|
+
CGFloat spreadRadii[8];
|
|
288
|
+
RnAppKitSpreadRadii(radii, shadow.spread, spreadRadii);
|
|
289
|
+
CGRect box = CGRectInset(layer.bounds, -shadow.spread, -shadow.spread);
|
|
290
|
+
box = CGRectOffset(box, shadow.dx, shadow.dy);
|
|
291
|
+
CGPathRef shadowPath = RnAppKitCreateRoundedPath(box, spreadRadii);
|
|
292
|
+
layer.shadowPath = shadowPath;
|
|
293
|
+
|
|
294
|
+
// Everything outside the view's own box, as an even-odd pair: the box, and a
|
|
295
|
+
// rectangle big enough to hold the whole blur around it.
|
|
296
|
+
CAShapeLayer *mask = [CAShapeLayer layer];
|
|
297
|
+
mask.fillRule = kCAFillRuleEvenOdd;
|
|
298
|
+
CGMutablePathRef maskPath = CGPathCreateMutable();
|
|
299
|
+
CGPathRef viewPath = RnAppKitCreateRoundedPath(layer.bounds, radii);
|
|
300
|
+
CGPathAddPath(maskPath, NULL, viewPath);
|
|
301
|
+
const CGFloat room = 2 * (fabs(shadow.blur) + 1);
|
|
302
|
+
CGPathRef around = RnAppKitCreateRoundedPath(CGRectInset(box, -room, -room), spreadRadii);
|
|
303
|
+
CGPathAddPath(maskPath, NULL, around);
|
|
304
|
+
mask.path = maskPath;
|
|
305
|
+
layer.mask = mask;
|
|
306
|
+
|
|
307
|
+
CGPathRelease(maskPath);
|
|
308
|
+
CGPathRelease(viewPath);
|
|
309
|
+
CGPathRelease(around);
|
|
310
|
+
CGPathRelease(shadowPath);
|
|
311
|
+
return layer;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// A shadow cast inward from the box's own edge.
|
|
315
|
+
//
|
|
316
|
+
// Built from the hole it leaves: the shadow path is a wide rectangle with the
|
|
317
|
+
// clear region punched out of it, so the blur spills inward from the edge of
|
|
318
|
+
// that hole, and the mask keeps all of it inside the view. The clear region is
|
|
319
|
+
// the box moved by the offset and shrunk by the spread, which is what makes an
|
|
320
|
+
// inset shadow with no offset a ring and one with an offset a crescent.
|
|
321
|
+
static CALayer *RnAppKitInsetShadowLayer(const RnAppKitBoxShadow &shadow,
|
|
322
|
+
const CGFloat radii[8],
|
|
323
|
+
CGSize size) {
|
|
324
|
+
CALayer *layer = [CALayer layer];
|
|
325
|
+
layer.frame = CGRectMake(0, 0, size.width, size.height);
|
|
326
|
+
layer.shadowOffset = CGSizeZero;
|
|
327
|
+
layer.shadowOpacity = 1;
|
|
328
|
+
layer.shadowRadius = shadow.blur > 0 ? shadow.blur / 2 : 0;
|
|
329
|
+
|
|
330
|
+
CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
|
331
|
+
CGColorRef color = CGColorCreate(space, shadow.color);
|
|
332
|
+
layer.shadowColor = color;
|
|
333
|
+
CGColorRelease(color);
|
|
334
|
+
CGColorSpaceRelease(space);
|
|
335
|
+
|
|
336
|
+
const CGFloat room = fabs(shadow.blur) + 1;
|
|
337
|
+
CGMutablePathRef path = CGPathCreateMutable();
|
|
338
|
+
// Square, because this one is only there to be the outside of the hole: its
|
|
339
|
+
// own corners are beyond the mask and never seen.
|
|
340
|
+
const CGFloat square[8] = {0, 0, 0, 0, 0, 0, 0, 0};
|
|
341
|
+
CGPathRef outer = RnAppKitCreateRoundedPath(CGRectInset(layer.bounds, -room, -room), square);
|
|
342
|
+
CGPathAddPath(path, NULL, outer);
|
|
343
|
+
|
|
344
|
+
CGFloat holeRadii[8];
|
|
345
|
+
RnAppKitSpreadRadii(radii, -shadow.spread, holeRadii);
|
|
346
|
+
CGRect hole = CGRectOffset(layer.bounds, shadow.dx, shadow.dy);
|
|
347
|
+
hole = CGRectInset(hole, shadow.spread, shadow.spread);
|
|
348
|
+
if (CGRectIsNull(hole) || hole.size.width < 0 || hole.size.height < 0) {
|
|
349
|
+
hole = CGRectZero;
|
|
350
|
+
}
|
|
351
|
+
CGPathRef holePath = RnAppKitCreateRoundedPath(hole, holeRadii);
|
|
352
|
+
CGPathRef reversedHole = RnAppKitCreateReversedPath(holePath);
|
|
353
|
+
CGPathAddPath(path, NULL, reversedHole);
|
|
354
|
+
layer.shadowPath = path;
|
|
355
|
+
|
|
356
|
+
CAShapeLayer *mask = [CAShapeLayer layer];
|
|
357
|
+
CGPathRef viewPath = RnAppKitCreateRoundedPath(layer.bounds, radii);
|
|
358
|
+
mask.path = viewPath;
|
|
359
|
+
layer.mask = mask;
|
|
360
|
+
|
|
361
|
+
CGPathRelease(path);
|
|
362
|
+
CGPathRelease(outer);
|
|
363
|
+
CGPathRelease(holePath);
|
|
364
|
+
CGPathRelease(reversedHole);
|
|
365
|
+
CGPathRelease(viewPath);
|
|
366
|
+
return layer;
|
|
367
|
+
}
|
|
368
|
+
|
|
159
369
|
// Clips to the side of the line through `p` with direction `dir` that contains
|
|
160
370
|
// `inside`. Two of these give an edge the sector it owns at a corner.
|
|
161
371
|
//
|
|
@@ -204,6 +414,14 @@ static void RnAppKitClipToHalfPlane(CGContextRef context,
|
|
|
204
414
|
@property(nonatomic) CGFloat rnVerticalLength;
|
|
205
415
|
@property(nonatomic) CGFloat rnHorizontalOffset;
|
|
206
416
|
@property(nonatomic) CGFloat rnHorizontalLength;
|
|
417
|
+
// `indicatorStyle`, as the colour core resolved it to. Black and translucent
|
|
418
|
+
// until something says otherwise.
|
|
419
|
+
@property(nonatomic) NSColor *rnThumbColour;
|
|
420
|
+
// The hairline around it, which `indicatorStyle: 'default'` has and the other
|
|
421
|
+
// two do not: iOS's default thumb is black with a white border, and that
|
|
422
|
+
// border is the whole difference between `default` and `black`. Nil, or a
|
|
423
|
+
// transparent colour, means none.
|
|
424
|
+
@property(nonatomic) NSColor *rnThumbBorderColour;
|
|
207
425
|
@end
|
|
208
426
|
|
|
209
427
|
@implementation RnAppKitScrollIndicatorView
|
|
@@ -228,6 +446,28 @@ static void RnAppKitClipToHalfPlane(CGContextRef context,
|
|
|
228
446
|
CGPathRelease(path);
|
|
229
447
|
}
|
|
230
448
|
|
|
449
|
+
// The hairline, stroked inside the pill it belongs to: a stroke straddles its
|
|
450
|
+
// path, so the rectangle is inset by half the width and the line lands on the
|
|
451
|
+
// thumb rather than half outside it.
|
|
452
|
+
- (void)rnStrokePill:(NSRect)bar inContext:(CGContextRef)context {
|
|
453
|
+
NSColor *const edge = _rnThumbBorderColour;
|
|
454
|
+
if (edge == nil || edge.alphaComponent <= 0) {
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
const CGFloat width = basalt::kScrollIndicatorBorderWidth;
|
|
458
|
+
const NSRect inner = NSInsetRect(bar, width / 2.0, width / 2.0);
|
|
459
|
+
const CGFloat radius = MAX((CGFloat)0, basalt::kScrollIndicatorThickness / 2.0 - width / 2.0);
|
|
460
|
+
CGPathRef path = CGPathCreateWithRoundedRect(inner, radius, radius, NULL);
|
|
461
|
+
CGContextSaveGState(context);
|
|
462
|
+
CGContextSetRGBStrokeColor(context, edge.redComponent, edge.greenComponent, edge.blueComponent,
|
|
463
|
+
edge.alphaComponent);
|
|
464
|
+
CGContextSetLineWidth(context, width);
|
|
465
|
+
CGContextAddPath(context, path);
|
|
466
|
+
CGContextStrokePath(context);
|
|
467
|
+
CGContextRestoreGState(context);
|
|
468
|
+
CGPathRelease(path);
|
|
469
|
+
}
|
|
470
|
+
|
|
231
471
|
- (void)drawRect:(NSRect)dirtyRect {
|
|
232
472
|
(void)dirtyRect;
|
|
233
473
|
if (_rnVerticalLength <= 0.0 && _rnHorizontalLength <= 0.0) {
|
|
@@ -237,23 +477,33 @@ static void RnAppKitClipToHalfPlane(CGContextRef context,
|
|
|
237
477
|
const NSSize size = self.bounds.size;
|
|
238
478
|
const CGFloat thickness = basalt::kScrollIndicatorThickness;
|
|
239
479
|
const CGFloat inset = basalt::kScrollIndicatorInset;
|
|
240
|
-
// Neutral and translucent, so it reads over light and dark content alike
|
|
241
|
-
//
|
|
242
|
-
|
|
480
|
+
// Neutral and translucent, so it reads over light and dark content alike, and
|
|
481
|
+
// white instead when `indicatorStyle` asked for it. Core decides which, so
|
|
482
|
+
// the GTK and Win32 hosts answer the prop with the same colour.
|
|
483
|
+
NSColor *const thumb = _rnThumbColour != nil
|
|
484
|
+
? _rnThumbColour
|
|
485
|
+
: [NSColor colorWithSRGBRed:0.0 green:0.0 blue:0.0 alpha:basalt::kScrollIndicatorAlpha];
|
|
486
|
+
CGContextSetRGBFillColor(context,
|
|
487
|
+
thumb.redComponent,
|
|
488
|
+
thumb.greenComponent,
|
|
489
|
+
thumb.blueComponent,
|
|
490
|
+
thumb.alphaComponent);
|
|
243
491
|
|
|
244
492
|
if (_rnVerticalLength > 0.0) {
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
493
|
+
const NSRect bar = NSMakeRect(size.width - thickness - inset,
|
|
494
|
+
_rnVerticalOffset,
|
|
495
|
+
thickness,
|
|
496
|
+
_rnVerticalLength);
|
|
497
|
+
[self rnFillPill:bar inContext:context];
|
|
498
|
+
[self rnStrokePill:bar inContext:context];
|
|
250
499
|
}
|
|
251
500
|
if (_rnHorizontalLength > 0.0) {
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
501
|
+
const NSRect bar = NSMakeRect(_rnHorizontalOffset,
|
|
502
|
+
size.height - thickness - inset,
|
|
503
|
+
_rnHorizontalLength,
|
|
504
|
+
thickness);
|
|
505
|
+
[self rnFillPill:bar inContext:context];
|
|
506
|
+
[self rnStrokePill:bar inContext:context];
|
|
257
507
|
}
|
|
258
508
|
}
|
|
259
509
|
|
|
@@ -265,13 +515,34 @@ static void RnAppKitClipToHalfPlane(CGContextRef context,
|
|
|
265
515
|
std::vector<float> _highlights;
|
|
266
516
|
std::vector<bool> _highlightFilled;
|
|
267
517
|
NSString *_roleName;
|
|
518
|
+
// The origin React Native gave this view, which is not where the view sits
|
|
519
|
+
// when its paragraph moved its text. See setRnFrameX:y:width:height:.
|
|
520
|
+
NSPoint _rnOrigin;
|
|
268
521
|
RnTextLayout *_textLayout;
|
|
522
|
+
// `<Text selectable>`; the range itself lives on the layout, which is what
|
|
523
|
+
// draws it. See setRnTextSelectable.
|
|
524
|
+
BOOL _textSelectable;
|
|
269
525
|
CGImageRef _image;
|
|
526
|
+
// An animated image's frames, their delays as the file carried them, and how
|
|
527
|
+
// many times round. See setRnImageFrames:delaysMs:loopCount:. `_image` is
|
|
528
|
+
// whichever frame is showing, so everything that draws stays unchanged.
|
|
529
|
+
NSArray *_imageFrames;
|
|
530
|
+
std::vector<unsigned> _imageDelays;
|
|
531
|
+
NSUInteger _imageLoopCount;
|
|
532
|
+
NSUInteger _imageFrame;
|
|
533
|
+
double _imageElapsedMs;
|
|
534
|
+
NSTimer *_imageTimer;
|
|
270
535
|
NSColor *_imageTint;
|
|
271
536
|
RnAppKitImageFit _imageFit;
|
|
537
|
+
CGFloat _imageBlur;
|
|
538
|
+
// The blurred copy of _image, at the device pixel size it was last drawn at,
|
|
539
|
+
// or NULL when there is none to reuse. See -rnBlurredImageForSize:scale:.
|
|
540
|
+
CGImageRef _imageBlurred;
|
|
272
541
|
BOOL _hasBackgroundColor;
|
|
273
542
|
CGFloat _backgroundComponents[4];
|
|
274
543
|
CGFloat _opacity;
|
|
544
|
+
// The `opacity()` out of the filter list, 1 when there is none.
|
|
545
|
+
CGFloat _filterOpacity;
|
|
275
546
|
BOOL _clipsChildren;
|
|
276
547
|
CGFloat _cornerRadius;
|
|
277
548
|
// Eight floats: a horizontal and a vertical radius per corner, in the order
|
|
@@ -285,6 +556,39 @@ static void RnAppKitClipToHalfPlane(CGContextRef context,
|
|
|
285
556
|
// Four RGBA quadruples, in the same edge order.
|
|
286
557
|
CGFloat _borderColors[16];
|
|
287
558
|
BOOL _hasBorders;
|
|
559
|
+
// CSS's outline, and the layer it became. Zero width is none.
|
|
560
|
+
CGFloat _outlineWidth;
|
|
561
|
+
CGFloat _outlineOffset;
|
|
562
|
+
CGFloat _outlineColor[4];
|
|
563
|
+
RnAppKitBorderStyle _outlineStyle;
|
|
564
|
+
CAShapeLayer *_outlineLayer;
|
|
565
|
+
// The resolved `filter`, as the Core Image filters it became, or nil.
|
|
566
|
+
NSArray<CIFilter *> *_filters;
|
|
567
|
+
// `hitSlop`, top, right, bottom, left. Read only by the hit test.
|
|
568
|
+
CGFloat _hitSlop[4];
|
|
569
|
+
BOOL _hasHitSlop;
|
|
570
|
+
RnAppKitBorderStyle _borderStyle;
|
|
571
|
+
std::vector<RnAppKitBoxShadow> _boxShadows;
|
|
572
|
+
// The `backgroundImage` gradients, each with its own stops.
|
|
573
|
+
std::vector<RnAppKitGradientRecord> _gradients;
|
|
574
|
+
NSMutableArray<CALayer *> *_boxShadowLayers;
|
|
575
|
+
// The second and later `dropShadow()` entries, a layer each. See
|
|
576
|
+
// RnAppKitFilterShadowLayer.
|
|
577
|
+
NSMutableArray<CALayer *> *_filterShadowLayers;
|
|
578
|
+
std::vector<RnAppKitFilterShadow> _extraFilterShadows;
|
|
579
|
+
NSString *_cursorName;
|
|
580
|
+
// `mixBlendMode`: the keyword asked for, and the Core Image filter it became.
|
|
581
|
+
// The `dropShadow()` from `filter`, as the layer's own shadow.
|
|
582
|
+
RnAppKitFilterShadow _filterShadow;
|
|
583
|
+
NSString *_blendModeName;
|
|
584
|
+
NSString *_blendFilterName;
|
|
585
|
+
// The last text this view announced as a live region. See -rnAnnounce:.
|
|
586
|
+
NSString *_lastAnnouncement;
|
|
587
|
+
// The tags of the views in this one's labelled-by relation, for the tree dump.
|
|
588
|
+
std::vector<NSInteger> _labelledBy;
|
|
589
|
+
// The resolved `experimental_accessibilityOrder`, by tag, for the dump.
|
|
590
|
+
std::vector<NSInteger> _accessibilityOrder;
|
|
591
|
+
NSCursor *_cursor;
|
|
288
592
|
CATransform3D _transform;
|
|
289
593
|
BOOL _hasTransform;
|
|
290
594
|
BOOL _hidesBackFace;
|
|
@@ -295,6 +599,13 @@ static void RnAppKitClipToHalfPlane(CGContextRef context,
|
|
|
295
599
|
NSTrackingArea *_rnHoverTrackingArea;
|
|
296
600
|
// Only a ScrollView with something to indicate has one.
|
|
297
601
|
RnAppKitScrollIndicatorView *_indicators;
|
|
602
|
+
// `indicatorStyle`'s colour, held here because the overlay above is made and
|
|
603
|
+
// thrown away as the content grows past the viewport and shrinks back inside
|
|
604
|
+
// it. Nil until a prop says otherwise, which the drawing reads as the
|
|
605
|
+
// default.
|
|
606
|
+
NSColor *_rnIndicatorColour;
|
|
607
|
+
// The hairline around the thumb, which only `indicatorStyle: 'default'` has.
|
|
608
|
+
NSColor *_rnIndicatorBorderColour;
|
|
298
609
|
}
|
|
299
610
|
|
|
300
611
|
+ (instancetype)viewWithTag:(NSInteger)tag {
|
|
@@ -305,12 +616,28 @@ static void RnAppKitClipToHalfPlane(CGContextRef context,
|
|
|
305
616
|
|
|
306
617
|
- (void)dealloc {
|
|
307
618
|
CGImageRelease(_image);
|
|
619
|
+
CGImageRelease(_imageBlurred);
|
|
620
|
+
// A scheduled timer holds its target; this one holds `self` weakly so a view
|
|
621
|
+
// released mid-animation is not kept alive by it, and the timer still has to
|
|
622
|
+
// go or it fires into nothing once a tenth of a second.
|
|
623
|
+
[_imageTimer invalidate];
|
|
624
|
+
// An outset shadow's layer lives in the superlayer, so it outlives this view
|
|
625
|
+
// unless it is taken out. Ordinarily -viewDidMoveToSuperview has already done
|
|
626
|
+
// it; this is for a view released without being unmounted first.
|
|
627
|
+
for (CALayer *layer in _boxShadowLayers) {
|
|
628
|
+
[layer removeFromSuperlayer];
|
|
629
|
+
}
|
|
630
|
+
// The outline can be in the parent's layer for the same reason, so it has to
|
|
631
|
+
// come out too.
|
|
632
|
+
[_outlineLayer removeFromSuperlayer];
|
|
308
633
|
}
|
|
309
634
|
|
|
310
635
|
- (instancetype)initWithFrame:(NSRect)frame {
|
|
311
636
|
self = [super initWithFrame:frame];
|
|
312
637
|
if (self != nil) {
|
|
638
|
+
_rnOrigin = frame.origin;
|
|
313
639
|
_opacity = 1.0;
|
|
640
|
+
_filterOpacity = 1.0;
|
|
314
641
|
_imageFit = RnAppKitImageFitCover;
|
|
315
642
|
_transform = CATransform3DIdentity;
|
|
316
643
|
// Layer-backed from the start rather than on demand: a view that acquires a
|
|
@@ -330,7 +657,15 @@ static void RnAppKitClipToHalfPlane(CGContextRef context,
|
|
|
330
657
|
}
|
|
331
658
|
|
|
332
659
|
- (void)setRnFrameX:(CGFloat)x y:(CGFloat)y width:(CGFloat)width height:(CGFloat)height {
|
|
333
|
-
|
|
660
|
+
// What React Native decided, kept because the view may not sit there: a view
|
|
661
|
+
// inline in a paragraph is moved by whatever `textAlignVertical` did to the
|
|
662
|
+
// text, and when that changes it has to be placed again from this rather
|
|
663
|
+
// than from where it currently is. See rnPlaceInlineChildren.
|
|
664
|
+
_rnOrigin = NSMakePoint(x, y);
|
|
665
|
+
self.frame = NSMakeRect(x, y + [self rnParentTextOffset], width, height);
|
|
666
|
+
// A box that changed height changes where a bottom- or middle-aligned
|
|
667
|
+
// paragraph sits, and therefore where the views inline in it sit.
|
|
668
|
+
[self rnPlaceInlineChildren];
|
|
334
669
|
// A mask layer is built against the bounds, so a resize invalidates it. The
|
|
335
670
|
// uniform case is a plain cornerRadius and needs nothing.
|
|
336
671
|
if (self.layer.mask != nil) {
|
|
@@ -340,6 +675,16 @@ static void RnAppKitClipToHalfPlane(CGContextRef context,
|
|
|
340
675
|
if (_hasBorders) {
|
|
341
676
|
self.needsDisplay = YES;
|
|
342
677
|
}
|
|
678
|
+
// And a box shadow's path is in the view's own coordinates, so every one of
|
|
679
|
+
// them has to be rebuilt: a card that grew would otherwise keep the shadow of
|
|
680
|
+
// the size it used to be. The outline is in the same position.
|
|
681
|
+
if (!_boxShadows.empty()) {
|
|
682
|
+
[self rnRebuildBoxShadowLayers];
|
|
683
|
+
[self rnRebuildFilterShadowLayers];
|
|
684
|
+
}
|
|
685
|
+
if (_outlineWidth > 0) {
|
|
686
|
+
[self rnRebuildOutlineLayer];
|
|
687
|
+
}
|
|
343
688
|
}
|
|
344
689
|
|
|
345
690
|
- (void)setRnBackgroundColorRed:(CGFloat)red
|
|
@@ -431,11 +776,43 @@ static NSAccessibilityRole RnAccessibilityRoleFor(NSString *name) {
|
|
|
431
776
|
return role != nil ? role : NSAccessibilityGroupRole;
|
|
432
777
|
}
|
|
433
778
|
|
|
779
|
+
// The subrole for a React Native role, or nil.
|
|
780
|
+
//
|
|
781
|
+
// macOS says some things with a role and the rest with a *subrole*, and four of
|
|
782
|
+
// React Native's roles are in the second group: the role above reports a search
|
|
783
|
+
// field as a text field, a switch and a toggle button as check boxes, and a tab
|
|
784
|
+
// as a radio button, each of which loses the part that says what it is. GTK
|
|
785
|
+
// needs none of this -- `GTK_ACCESSIBLE_ROLE_SEARCH_BOX`, `_SWITCH` and `_TAB`
|
|
786
|
+
// are roles there -- which is why this was a macOS entry.
|
|
787
|
+
//
|
|
788
|
+
// Nothing else gets one. A subrole that is not one of AppKit's own tells
|
|
789
|
+
// VoiceOver less than no subrole, and a view with no subrole is read by its
|
|
790
|
+
// role, which is already the nearest thing.
|
|
791
|
+
static NSAccessibilitySubrole RnAccessibilitySubroleFor(NSString *name) {
|
|
792
|
+
static NSDictionary<NSString *, NSAccessibilitySubrole> *subroles = nil;
|
|
793
|
+
static dispatch_once_t once;
|
|
794
|
+
dispatch_once(&once, ^{
|
|
795
|
+
subroles = @{
|
|
796
|
+
@"search" : NSAccessibilitySearchFieldSubrole,
|
|
797
|
+
// A switch and a toggle button are both check boxes by role, and these
|
|
798
|
+
// are what tell them apart: "switch" and "toggle".
|
|
799
|
+
@"switch" : NSAccessibilitySwitchSubrole,
|
|
800
|
+
@"togglebutton" : NSAccessibilityToggleSubrole,
|
|
801
|
+
// A tab is a radio button by role, which reads oddly on its own.
|
|
802
|
+
@"tab" : NSAccessibilityTabButtonSubrole,
|
|
803
|
+
};
|
|
804
|
+
});
|
|
805
|
+
return subroles[name];
|
|
806
|
+
}
|
|
807
|
+
|
|
434
808
|
- (void)setRnAccessibleRole:(NSString *)role {
|
|
435
809
|
_roleName = role.length > 0 ? [role copy] : nil;
|
|
436
810
|
|
|
437
811
|
if (_roleName == nil) {
|
|
438
812
|
self.accessibilityRole = NSAccessibilityGroupRole;
|
|
813
|
+
// Cleared as well as set: a view whose role prop goes away must not keep
|
|
814
|
+
// the subrole its last role carried.
|
|
815
|
+
self.accessibilitySubrole = nil;
|
|
439
816
|
// A plain <View> is scenery. Leaving every one of them in the tree would
|
|
440
817
|
// bury the handful that mean something under hundreds that do not.
|
|
441
818
|
self.accessibilityElement = NO;
|
|
@@ -444,9 +821,207 @@ static NSAccessibilityRole RnAccessibilityRoleFor(NSString *name) {
|
|
|
444
821
|
|
|
445
822
|
const NSAccessibilityRole mapped = RnAccessibilityRoleFor(_roleName);
|
|
446
823
|
self.accessibilityRole = mapped;
|
|
824
|
+
self.accessibilitySubrole = RnAccessibilitySubroleFor(_roleName);
|
|
447
825
|
self.accessibilityElement = mapped != NSAccessibilityUnknownRole;
|
|
448
826
|
}
|
|
449
827
|
|
|
828
|
+
// The text of one view and everything under it, appended to `out`.
|
|
829
|
+
//
|
|
830
|
+
// Paragraphs only. A label can stand in for text, and the mounting manager
|
|
831
|
+
// prefers one before asking for this, as the GTK host does -- which keeps the
|
|
832
|
+
// rule in one place per host rather than in the view layer, where the props are
|
|
833
|
+
// not.
|
|
834
|
+
static void RnAppKitCollectText(RnAppKitView *view, NSMutableString *out) {
|
|
835
|
+
NSString *own = view.rnOwnText;
|
|
836
|
+
if (own.length > 0) {
|
|
837
|
+
if (out.length > 0) {
|
|
838
|
+
[out appendString:@" "];
|
|
839
|
+
}
|
|
840
|
+
[out appendString:own];
|
|
841
|
+
}
|
|
842
|
+
for (RnAppKitView *child in [view rnChildrenInPaintOrder]) {
|
|
843
|
+
RnAppKitCollectText(child, out);
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
- (NSString *)rnOwnText {
|
|
848
|
+
return _textLayout.attributedString.string;
|
|
849
|
+
}
|
|
850
|
+
|
|
851
|
+
- (NSString *)rnCollectedText {
|
|
852
|
+
NSMutableString *out = [NSMutableString string];
|
|
853
|
+
RnAppKitCollectText(self, out);
|
|
854
|
+
return out;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
- (void)rnAnnounce:(NSString *)text assertive:(BOOL)assertive {
|
|
858
|
+
if (text.length == 0) {
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
_lastAnnouncement = [text copy];
|
|
862
|
+
// Posted against the application rather than this view: an announcement is not
|
|
863
|
+
// about an element, and AppKit documents the app as the element to post it on.
|
|
864
|
+
NSAccessibilityPostNotificationWithUserInfo(
|
|
865
|
+
NSApp,
|
|
866
|
+
NSAccessibilityAnnouncementRequestedNotification,
|
|
867
|
+
@{
|
|
868
|
+
NSAccessibilityAnnouncementKey : text,
|
|
869
|
+
NSAccessibilityPriorityKey : @(assertive ? NSAccessibilityPriorityHigh
|
|
870
|
+
: NSAccessibilityPriorityMedium),
|
|
871
|
+
});
|
|
872
|
+
}
|
|
873
|
+
|
|
874
|
+
- (NSString *)rnLastAnnouncement {
|
|
875
|
+
return _lastAnnouncement;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
// --- What a screen reader reads out of a paragraph ---------------------------
|
|
879
|
+
//
|
|
880
|
+
// A `<Text>` here is an `RnTextLayout` drawn into a view, not an NSTextField,
|
|
881
|
+
// so VoiceOver saw an element with a label and nothing to move through: no
|
|
882
|
+
// caret, no lines, and no sign of a selection the person had just dragged out
|
|
883
|
+
// with the mouse. backlog/text.md's selection entry records that half as the
|
|
884
|
+
// one left after the selecting worked, and these are the AppKit side of it.
|
|
885
|
+
//
|
|
886
|
+
// Every index is a UTF-16 code unit, which is what `NSAttributedString`
|
|
887
|
+
// counts, what `RnTextLayout.selection` already holds, and what NSAccessibility
|
|
888
|
+
// asks in -- so unlike the GTK half, which converts bytes to characters,
|
|
889
|
+
// nothing here converts. The ranges are clamped rather than trusted:
|
|
890
|
+
// VoiceOver asks about ranges a paragraph has shrunk out of.
|
|
891
|
+
//
|
|
892
|
+
// `accessibilityValue` is deliberately left alone. React Native models it as a
|
|
893
|
+
// range for a slider and as the checked state for a checkbox, both of which
|
|
894
|
+
// -applyAccessibility already puts there; a paragraph's string is its
|
|
895
|
+
// accessibilityLabel, which is set from the same place.
|
|
896
|
+
- (NSString *)rnAccessibilityText {
|
|
897
|
+
NSString *text = _textLayout.attributedString.string;
|
|
898
|
+
return text != nil ? text : @"";
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
- (NSRange)rnClampedRange:(NSRange)range {
|
|
902
|
+
const NSUInteger length = [self rnAccessibilityText].length;
|
|
903
|
+
if (range.location >= length) {
|
|
904
|
+
return NSMakeRange(length, 0);
|
|
905
|
+
}
|
|
906
|
+
return NSMakeRange(range.location, MIN(range.length, length - range.location));
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
- (NSInteger)accessibilityNumberOfCharacters {
|
|
910
|
+
return (NSInteger)[self rnAccessibilityText].length;
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
- (NSRange)accessibilityVisibleCharacterRange {
|
|
914
|
+
// All of it: a paragraph here is drawn whole or clipped by `numberOfLines`,
|
|
915
|
+
// and the clipped case already reports the shown lines through
|
|
916
|
+
// -lineRangesForWidth:, which is what the line queries below use.
|
|
917
|
+
return NSMakeRange(0, [self rnAccessibilityText].length);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
- (NSString *)accessibilityStringForRange:(NSRange)range {
|
|
921
|
+
const NSRange wanted = [self rnClampedRange:range];
|
|
922
|
+
if (wanted.length == 0) {
|
|
923
|
+
return @"";
|
|
924
|
+
}
|
|
925
|
+
return [[self rnAccessibilityText] substringWithRange:wanted];
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
- (NSAttributedString *)accessibilityAttributedStringForRange:(NSRange)range {
|
|
929
|
+
const NSRange wanted = [self rnClampedRange:range];
|
|
930
|
+
if (_textLayout == nil || wanted.length == 0) {
|
|
931
|
+
return [[NSAttributedString alloc] initWithString:@""];
|
|
932
|
+
}
|
|
933
|
+
// The real attributed string, so the font and colour of each run reach
|
|
934
|
+
// VoiceOver rather than a plain copy of the characters.
|
|
935
|
+
return [_textLayout.attributedString attributedSubstringFromRange:wanted];
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
- (NSString *)accessibilitySelectedText {
|
|
939
|
+
NSString *selected = [_textLayout selectedText];
|
|
940
|
+
return selected != nil ? selected : @"";
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
- (NSRange)accessibilitySelectedTextRange {
|
|
944
|
+
if (_textLayout == nil) {
|
|
945
|
+
return NSMakeRange(0, 0);
|
|
946
|
+
}
|
|
947
|
+
return [self rnClampedRange:_textLayout.selection];
|
|
948
|
+
}
|
|
949
|
+
|
|
950
|
+
- (NSArray<NSValue *> *)accessibilitySelectedTextRanges {
|
|
951
|
+
return @[ [NSValue valueWithRange:[self accessibilitySelectedTextRange]] ];
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
// The lines are Core Text's own, at the width this view is, so they are the
|
|
955
|
+
// ones on the screen rather than a second guess at where the text wrapped.
|
|
956
|
+
- (NSArray<NSValue *> *)rnAccessibilityLineRanges {
|
|
957
|
+
if (_textLayout == nil) {
|
|
958
|
+
return @[];
|
|
959
|
+
}
|
|
960
|
+
return [_textLayout lineRangesForWidth:self.bounds.size.width];
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
- (NSInteger)accessibilityLineForIndex:(NSInteger)index {
|
|
964
|
+
NSArray<NSValue *> *lines = [self rnAccessibilityLineRanges];
|
|
965
|
+
for (NSUInteger line = 0; line < lines.count; line++) {
|
|
966
|
+
const NSRange range = lines[line].rangeValue;
|
|
967
|
+
if ((NSUInteger)index < NSMaxRange(range)) {
|
|
968
|
+
return (NSInteger)line;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
// Past the end, which is where a caret after the last character sits.
|
|
972
|
+
return lines.count > 0 ? (NSInteger)lines.count - 1 : 0;
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
- (NSRange)accessibilityRangeForLine:(NSInteger)line {
|
|
976
|
+
NSArray<NSValue *> *lines = [self rnAccessibilityLineRanges];
|
|
977
|
+
if (line < 0 || (NSUInteger)line >= lines.count) {
|
|
978
|
+
return NSMakeRange(NSNotFound, 0);
|
|
979
|
+
}
|
|
980
|
+
return lines[(NSUInteger)line].rangeValue;
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
- (NSRange)accessibilityRangeForIndex:(NSInteger)index {
|
|
984
|
+
const NSUInteger length = [self rnAccessibilityText].length;
|
|
985
|
+
if (index < 0 || (NSUInteger)index >= length) {
|
|
986
|
+
return NSMakeRange(NSNotFound, 0);
|
|
987
|
+
}
|
|
988
|
+
// One composed character, so an emoji or a flag is one step rather than two
|
|
989
|
+
// halves of a surrogate pair.
|
|
990
|
+
return [[self rnAccessibilityText] rangeOfComposedCharacterSequenceAtIndex:(NSUInteger)index];
|
|
991
|
+
}
|
|
992
|
+
|
|
993
|
+
- (void)setRnLabelledBy:(NSArray<RnAppKitView *> *)labels {
|
|
994
|
+
_labelledBy.clear();
|
|
995
|
+
for (RnAppKitView *label in labels) {
|
|
996
|
+
_labelledBy.push_back(label.rnTag);
|
|
997
|
+
}
|
|
998
|
+
// The first, AppKit's property being one element rather than a list. Nil when
|
|
999
|
+
// there is none, which is how an element says it has no such relation.
|
|
1000
|
+
self.accessibilityTitleUIElement = labels.firstObject;
|
|
1001
|
+
// And a view named by another is worth announcing: the label is on the other
|
|
1002
|
+
// view, so without this a field whose only name comes from its caption stays
|
|
1003
|
+
// out of the tree.
|
|
1004
|
+
if (labels.count > 0) {
|
|
1005
|
+
self.accessibilityElement = YES;
|
|
1006
|
+
}
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
- (void)setRnAccessibilityOrder:(NSArray<RnAppKitView *> *)children {
|
|
1010
|
+
_accessibilityOrder.clear();
|
|
1011
|
+
for (RnAppKitView *child in children) {
|
|
1012
|
+
_accessibilityOrder.push_back(child.rnTag);
|
|
1013
|
+
}
|
|
1014
|
+
// `accessibilityChildren` replaces the children AppKit would have found by
|
|
1015
|
+
// walking the view hierarchy, which is exactly what the prop asks for: a
|
|
1016
|
+
// reading order that is not the mount order. Nil puts the hierarchy back,
|
|
1017
|
+
// which is what a view that stopped asking means.
|
|
1018
|
+
//
|
|
1019
|
+
// GTK says the same thing as a relation -- `aria-flowto` from this view to
|
|
1020
|
+
// each child -- rather than by replacing the children, because GTK's
|
|
1021
|
+
// accessible tree follows the widget tree and has no equivalent override.
|
|
1022
|
+
self.accessibilityChildren = children.count > 0 ? children : nil;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
450
1025
|
- (void)setRnAccessibleLabel:(NSString *)label hint:(NSString *)hint {
|
|
451
1026
|
self.accessibilityLabel = label.length > 0 ? label : nil;
|
|
452
1027
|
// `accessibilityHelp`, not `accessibilityValue`: React Native's hint is the
|
|
@@ -460,6 +1035,45 @@ static NSAccessibilityRole RnAccessibilityRoleFor(NSString *name) {
|
|
|
460
1035
|
}
|
|
461
1036
|
}
|
|
462
1037
|
|
|
1038
|
+
// `accessibilityValue`, which React Native models as a range (min, max, now) and
|
|
1039
|
+
// a text form that a screen reader prefers over the number.
|
|
1040
|
+
//
|
|
1041
|
+
// AppKit has `accessibilityMinValue` and `accessibilityMaxValue` for the range,
|
|
1042
|
+
// and one `accessibilityValue` that carries either the number or the text.
|
|
1043
|
+
// Setting nil is how an element says it has no such value, which matters because
|
|
1044
|
+
// an absent part must stay absent: a view that never said what its range is must
|
|
1045
|
+
// not be announced as sitting at the bottom of one.
|
|
1046
|
+
//
|
|
1047
|
+
// **This shares `accessibilityValue` with the checked state**, which AppKit
|
|
1048
|
+
// expresses as an element's value the way a checkbox does. An explicit
|
|
1049
|
+
// accessibilityValue wins, the app having said it outright, and the order below
|
|
1050
|
+
// is what decides that: state is applied first and this second. Both reaching
|
|
1051
|
+
// for the same property is an AppKit fact rather than a choice here.
|
|
1052
|
+
- (void)setRnAccessibleValueMin:(NSNumber *)min
|
|
1053
|
+
max:(NSNumber *)max
|
|
1054
|
+
now:(NSNumber *)now
|
|
1055
|
+
text:(NSString *)text {
|
|
1056
|
+
self.accessibilityMinValue = min;
|
|
1057
|
+
self.accessibilityMaxValue = max;
|
|
1058
|
+
|
|
1059
|
+
// The text form first: "Tuesday" reads better than "3", which is the whole
|
|
1060
|
+
// reason React Native carries both.
|
|
1061
|
+
if (text.length > 0) {
|
|
1062
|
+
self.accessibilityValue = text;
|
|
1063
|
+
} else if (now != nil) {
|
|
1064
|
+
self.accessibilityValue = now;
|
|
1065
|
+
} else if (min != nil || max != nil) {
|
|
1066
|
+
// A range with no position in it. Leaving a stale value here would be worse
|
|
1067
|
+
// than leaving none.
|
|
1068
|
+
self.accessibilityValue = nil;
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
if (min != nil || max != nil || now != nil || text.length > 0) {
|
|
1072
|
+
// Worth announcing, the same reason a label alone makes a view an element.
|
|
1073
|
+
self.accessibilityElement = YES;
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
1076
|
+
|
|
463
1077
|
- (void)setRnAccessibleStateDisabled:(RnAppKitAccessibleFlag)disabled
|
|
464
1078
|
checked:(RnAppKitAccessibleFlag)checked
|
|
465
1079
|
selected:(RnAppKitAccessibleFlag)selected
|
|
@@ -487,6 +1101,26 @@ static NSAccessibilityRole RnAccessibilityRoleFor(NSString *name) {
|
|
|
487
1101
|
(void)busy;
|
|
488
1102
|
}
|
|
489
1103
|
|
|
1104
|
+
- (void)setRnTestId:(NSString *)testId {
|
|
1105
|
+
if (_rnTestId == testId || [_rnTestId isEqualToString:testId]) {
|
|
1106
|
+
return;
|
|
1107
|
+
}
|
|
1108
|
+
_rnTestId = [testId copy];
|
|
1109
|
+
// The platform half, and the reason this is a setter rather than a plain
|
|
1110
|
+
// property: `accessibilityIdentifier` is what XCTest and the Accessibility
|
|
1111
|
+
// Inspector look a view up by, and it is the nearest thing macOS has to
|
|
1112
|
+
// UIA's automation id. An empty `testID` sets nil rather than an empty
|
|
1113
|
+
// string, which is what AppKit means by "no identifier".
|
|
1114
|
+
self.accessibilityIdentifier = _rnTestId.length > 0 ? _rnTestId : nil;
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
- (void)setRnAccessibleModal:(BOOL)modal {
|
|
1118
|
+
_rnAccessibleModal = modal;
|
|
1119
|
+
// AppKit's own flag, which VoiceOver reads: inside a modal element it stops
|
|
1120
|
+
// offering the siblings behind it.
|
|
1121
|
+
self.accessibilityModal = modal;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
490
1124
|
- (void)setRnAccessibleHidden:(BOOL)hidden {
|
|
491
1125
|
if (hidden) {
|
|
492
1126
|
self.accessibilityElement = NO;
|
|
@@ -523,6 +1157,8 @@ static NSAccessibilityRole RnAccessibilityRoleFor(NSString *name) {
|
|
|
523
1157
|
|
|
524
1158
|
if (_indicators == nil) {
|
|
525
1159
|
_indicators = [[RnAppKitScrollIndicatorView alloc] initWithFrame:self.bounds];
|
|
1160
|
+
_indicators.rnThumbColour = _rnIndicatorColour;
|
|
1161
|
+
_indicators.rnThumbBorderColour = _rnIndicatorBorderColour;
|
|
526
1162
|
[self addSubview:_indicators positioned:NSWindowAbove relativeTo:nil];
|
|
527
1163
|
}
|
|
528
1164
|
|
|
@@ -544,6 +1180,42 @@ static NSAccessibilityRole RnAccessibilityRoleFor(NSString *name) {
|
|
|
544
1180
|
_indicators.needsDisplay = YES;
|
|
545
1181
|
}
|
|
546
1182
|
|
|
1183
|
+
- (void)setRnScrollIndicatorColourRed:(CGFloat)red
|
|
1184
|
+
green:(CGFloat)green
|
|
1185
|
+
blue:(CGFloat)blue
|
|
1186
|
+
alpha:(CGFloat)alpha {
|
|
1187
|
+
// Held on this view rather than only on the overlay, because the overlay is
|
|
1188
|
+
// made and thrown away as the content grows past the viewport and shrinks
|
|
1189
|
+
// back inside it -- a colour kept only there would be lost each time.
|
|
1190
|
+
_rnIndicatorColour = [NSColor colorWithSRGBRed:red green:green blue:blue alpha:alpha];
|
|
1191
|
+
if (_indicators != nil && ![_indicators.rnThumbColour isEqual:_rnIndicatorColour]) {
|
|
1192
|
+
_indicators.rnThumbColour = _rnIndicatorColour;
|
|
1193
|
+
_indicators.needsDisplay = YES;
|
|
1194
|
+
}
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
- (NSColor *)rnScrollIndicatorColour {
|
|
1198
|
+
return _rnIndicatorColour;
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
- (void)setRnScrollIndicatorBorderColourRed:(CGFloat)red
|
|
1202
|
+
green:(CGFloat)green
|
|
1203
|
+
blue:(CGFloat)blue
|
|
1204
|
+
alpha:(CGFloat)alpha {
|
|
1205
|
+
// Held here for the reason the fill is: the overlay is made and thrown away
|
|
1206
|
+
// as the content grows past the viewport and shrinks back inside it, so a
|
|
1207
|
+
// colour kept only there would be lost each time.
|
|
1208
|
+
_rnIndicatorBorderColour = [NSColor colorWithSRGBRed:red green:green blue:blue alpha:alpha];
|
|
1209
|
+
if (_indicators != nil && ![_indicators.rnThumbBorderColour isEqual:_rnIndicatorBorderColour]) {
|
|
1210
|
+
_indicators.rnThumbBorderColour = _rnIndicatorBorderColour;
|
|
1211
|
+
_indicators.needsDisplay = YES;
|
|
1212
|
+
}
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
- (NSColor *)rnScrollIndicatorBorderColour {
|
|
1216
|
+
return _rnIndicatorBorderColour;
|
|
1217
|
+
}
|
|
1218
|
+
|
|
547
1219
|
static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
548
1220
|
switch (fit) {
|
|
549
1221
|
case RnAppKitImageFitContain:
|
|
@@ -568,7 +1240,54 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
568
1240
|
[self setNeedsDisplay:YES];
|
|
569
1241
|
}
|
|
570
1242
|
|
|
1243
|
+
- (void)setRnImageBlur:(CGFloat)radius {
|
|
1244
|
+
const CGFloat wanted = radius > 0 ? radius : 0;
|
|
1245
|
+
if (_imageBlur == wanted) {
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
_imageBlur = wanted;
|
|
1249
|
+
CGImageRelease(_imageBlurred);
|
|
1250
|
+
_imageBlurred = nullptr;
|
|
1251
|
+
[self setNeedsDisplay:YES];
|
|
1252
|
+
}
|
|
1253
|
+
|
|
1254
|
+
// The blurred copy to draw in place of the image, or NULL when there is no blur
|
|
1255
|
+
// or Core Image would not produce one.
|
|
1256
|
+
//
|
|
1257
|
+
// Kept on the view because `drawRect:` runs on every frame of a live resize and
|
|
1258
|
+
// a Core Image round trip per frame is not free, where the GTK side's blur is a
|
|
1259
|
+
// render node the GPU applies for nothing. The cache is keyed by the pixel size
|
|
1260
|
+
// it was made at, and thrown away whenever the image or the radius changes.
|
|
1261
|
+
- (CGImageRef)rnBlurredImageForSize:(CGSize)size scale:(CGFloat)scale {
|
|
1262
|
+
if (_image == nullptr || _imageBlur <= 0) {
|
|
1263
|
+
return nullptr;
|
|
1264
|
+
}
|
|
1265
|
+
const size_t width = (size_t)lround(size.width * scale);
|
|
1266
|
+
const size_t height = (size_t)lround(size.height * scale);
|
|
1267
|
+
if (width == 0 || height == 0) {
|
|
1268
|
+
return nullptr;
|
|
1269
|
+
}
|
|
1270
|
+
if (_imageBlurred != nullptr && CGImageGetWidth(_imageBlurred) == width &&
|
|
1271
|
+
CGImageGetHeight(_imageBlurred) == height) {
|
|
1272
|
+
return _imageBlurred;
|
|
1273
|
+
}
|
|
1274
|
+
CGImageRelease(_imageBlurred);
|
|
1275
|
+
_imageBlurred = RnBlurredImageCreate(_image, width, height, _imageBlur * scale / 2.0);
|
|
1276
|
+
return _imageBlurred;
|
|
1277
|
+
}
|
|
1278
|
+
|
|
571
1279
|
- (void)setRnImage:(CGImageRef)image fit:(RnAppKitImageFit)fit {
|
|
1280
|
+
// An animation owns `_image`, and the mounting manager re-applies the first
|
|
1281
|
+
// frame on every mutation that touched this view -- a layout-only one
|
|
1282
|
+
// included. Taking it would restart the GIF on every resize, so what is
|
|
1283
|
+
// taken here is the fit alone.
|
|
1284
|
+
if (_imageFrames.count > 1 && image == (__bridge CGImageRef)_imageFrames.firstObject) {
|
|
1285
|
+
if (_imageFit != fit) {
|
|
1286
|
+
_imageFit = fit;
|
|
1287
|
+
self.needsDisplay = YES;
|
|
1288
|
+
}
|
|
1289
|
+
return;
|
|
1290
|
+
}
|
|
572
1291
|
if (_image == image && _imageFit == fit) {
|
|
573
1292
|
return;
|
|
574
1293
|
}
|
|
@@ -577,10 +1296,176 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
577
1296
|
CGImageRef previous = _image;
|
|
578
1297
|
_image = image != nullptr ? CGImageRetain(image) : nullptr;
|
|
579
1298
|
CGImageRelease(previous);
|
|
1299
|
+
CGImageRelease(_imageBlurred);
|
|
1300
|
+
_imageBlurred = nullptr;
|
|
580
1301
|
_imageFit = fit;
|
|
581
1302
|
self.needsDisplay = YES;
|
|
582
1303
|
}
|
|
583
1304
|
|
|
1305
|
+
// The frames of an animated image. See the header.
|
|
1306
|
+
- (void)setRnImageFrames:(NSArray *)frames
|
|
1307
|
+
delaysMs:(NSArray<NSNumber *> *)delaysMs
|
|
1308
|
+
loopCount:(NSUInteger)loopCount {
|
|
1309
|
+
// The same animation again, which is what a layout-only mutation produces:
|
|
1310
|
+
// the loader hands back the arrays it cached, so this is a pointer compare,
|
|
1311
|
+
// and the animation keeps its place rather than starting over on every
|
|
1312
|
+
// resize.
|
|
1313
|
+
if (_imageFrames == frames && _imageLoopCount == loopCount) {
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
|
|
1317
|
+
[_imageTimer invalidate];
|
|
1318
|
+
_imageTimer = nil;
|
|
1319
|
+
_imageFrames = frames.count > 1 ? frames : nil;
|
|
1320
|
+
_imageLoopCount = _imageFrames != nil ? loopCount : 0;
|
|
1321
|
+
_imageFrame = 0;
|
|
1322
|
+
_imageElapsedMs = 0.0;
|
|
1323
|
+
_imageDelays.clear();
|
|
1324
|
+
if (_imageFrames == nil) {
|
|
1325
|
+
return;
|
|
1326
|
+
}
|
|
1327
|
+
for (NSNumber *delay in delaysMs) {
|
|
1328
|
+
_imageDelays.push_back(delay.unsignedIntValue);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
[self rnShowImageFrame:0];
|
|
1332
|
+
}
|
|
1333
|
+
|
|
1334
|
+
- (void)rnStartImageAnimation {
|
|
1335
|
+
if (_imageFrames.count < 2 || _imageTimer != nil) {
|
|
1336
|
+
return;
|
|
1337
|
+
}
|
|
1338
|
+
[self rnScheduleImageFrameIn:[self rnAdvanceImageAnimationBy:0.0]];
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
- (NSUInteger)rnImageFrameIndex {
|
|
1342
|
+
return _imageFrame;
|
|
1343
|
+
}
|
|
1344
|
+
|
|
1345
|
+
- (double)rnAdvanceImageAnimationBy:(double)milliseconds {
|
|
1346
|
+
if (_imageFrames.count < 2 || _imageDelays.size() != _imageFrames.count) {
|
|
1347
|
+
return 0.0;
|
|
1348
|
+
}
|
|
1349
|
+
if (milliseconds > 0.0) {
|
|
1350
|
+
_imageElapsedMs += milliseconds;
|
|
1351
|
+
}
|
|
1352
|
+
|
|
1353
|
+
const basalt::ImageAnimationStep step =
|
|
1354
|
+
basalt::imageAnimationStep(_imageDelays, static_cast<unsigned>(_imageLoopCount),
|
|
1355
|
+
static_cast<std::uint64_t>(_imageElapsedMs));
|
|
1356
|
+
if (step.frame != _imageFrame) {
|
|
1357
|
+
[self rnShowImageFrame:step.frame];
|
|
1358
|
+
}
|
|
1359
|
+
return step.nextInMs;
|
|
1360
|
+
}
|
|
1361
|
+
|
|
1362
|
+
// Puts one frame in `_image`, so that everything which draws an image draws
|
|
1363
|
+
// this one and nothing else in the view has to know about animation.
|
|
1364
|
+
- (void)rnShowImageFrame:(NSUInteger)index {
|
|
1365
|
+
if (index >= _imageFrames.count) {
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
_imageFrame = index;
|
|
1369
|
+
|
|
1370
|
+
CGImageRef previous = _image;
|
|
1371
|
+
_image = CGImageRetain((__bridge CGImageRef)_imageFrames[index]);
|
|
1372
|
+
CGImageRelease(previous);
|
|
1373
|
+
// The blur is of a frame, so it goes with the frame.
|
|
1374
|
+
CGImageRelease(_imageBlurred);
|
|
1375
|
+
_imageBlurred = nullptr;
|
|
1376
|
+
self.needsDisplay = YES;
|
|
1377
|
+
}
|
|
1378
|
+
|
|
1379
|
+
- (void)rnScheduleImageFrameIn:(double)milliseconds {
|
|
1380
|
+
[_imageTimer invalidate];
|
|
1381
|
+
_imageTimer = nil;
|
|
1382
|
+
if (milliseconds <= 0.0) {
|
|
1383
|
+
return;
|
|
1384
|
+
}
|
|
1385
|
+
|
|
1386
|
+
// An NSTimer rather than a CADisplayLink, which is what the scrolling uses.
|
|
1387
|
+
// A frame is due at a time the file names, so there is nothing to interpolate
|
|
1388
|
+
// and nothing to do on the frames in between; a display link would wake this
|
|
1389
|
+
// view sixty times a second to show the same picture.
|
|
1390
|
+
//
|
|
1391
|
+
// Weakly, so a view released mid-animation is not kept alive by its own
|
|
1392
|
+
// timer.
|
|
1393
|
+
__weak RnAppKitView *weakSelf = self;
|
|
1394
|
+
_imageTimer = [NSTimer scheduledTimerWithTimeInterval:milliseconds / 1000.0
|
|
1395
|
+
repeats:NO
|
|
1396
|
+
block:^(NSTimer *timer) {
|
|
1397
|
+
RnAppKitView *view = weakSelf;
|
|
1398
|
+
if (view == nil) {
|
|
1399
|
+
[timer invalidate];
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
// By the delay asked for
|
|
1403
|
+
// rather than by the wall
|
|
1404
|
+
// clock: a late timer makes
|
|
1405
|
+
// the animation run slow
|
|
1406
|
+
// instead of skipping
|
|
1407
|
+
// frames, which is the
|
|
1408
|
+
// kinder failure for a
|
|
1409
|
+
// four-frame spinner.
|
|
1410
|
+
const double next =
|
|
1411
|
+
[view rnAdvanceImageAnimationBy:milliseconds];
|
|
1412
|
+
[view rnScheduleImageFrameIn:next];
|
|
1413
|
+
}];
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
// `image`, blurred, at `width` by `height` device pixels.
|
|
1417
|
+
//
|
|
1418
|
+
// Blurred at the size it will be drawn rather than in its own pixels, so that
|
|
1419
|
+
// the radius is a distance on screen. Blurring in the source's pixels instead
|
|
1420
|
+
// would make one prop almost invisible on a large photograph and overwhelming
|
|
1421
|
+
// on an icon, and would not agree with the GTK side, which pushes a GSK blur
|
|
1422
|
+
// node in widget coordinates.
|
|
1423
|
+
//
|
|
1424
|
+
// Sigma is half the radius, which is measured rather than chosen. A hard edge
|
|
1425
|
+
// blurred by `gtk_snapshot_push_blur` comes out at sigma = 0.47 * radius under
|
|
1426
|
+
// GTK's GL renderer, measured off a downloaded texture; React Native's own iOS
|
|
1427
|
+
// path blurs with three box convolutions of
|
|
1428
|
+
// `floor((radius * scale * 3 * sqrt(2 * pi) / 4 + 0.5) / 2) | 1`, whose variance
|
|
1429
|
+
// works out to the same 0.47. Half the radius is what both of those agree on,
|
|
1430
|
+
// within the few percent between a true gaussian and three boxes, and it is what
|
|
1431
|
+
// the test here measures on the same kind of edge.
|
|
1432
|
+
static CGImageRef RnBlurredImageCreate(CGImageRef image,
|
|
1433
|
+
size_t width,
|
|
1434
|
+
size_t height,
|
|
1435
|
+
CGFloat sigma) {
|
|
1436
|
+
CIImage *source = [CIImage imageWithCGImage:image];
|
|
1437
|
+
const CGRect extent = source.extent;
|
|
1438
|
+
if (extent.size.width <= 0 || extent.size.height <= 0) {
|
|
1439
|
+
return nullptr;
|
|
1440
|
+
}
|
|
1441
|
+
CIImage *scaled = [source
|
|
1442
|
+
imageByApplyingTransform:CGAffineTransformMakeScale((CGFloat)width / extent.size.width,
|
|
1443
|
+
(CGFloat)height / extent.size.height)];
|
|
1444
|
+
// Clamped before the blur and cropped after it. Without the clamp the edges
|
|
1445
|
+
// blur out into transparency, so a `cover` photograph would gain a soft frame
|
|
1446
|
+
// it has on no other platform: iOS extends its edge pixels instead
|
|
1447
|
+
// (kvImageEdgeExtend) and GSK blurs inside the clip.
|
|
1448
|
+
CIImage *blurred =
|
|
1449
|
+
[[scaled imageByClampingToExtent] imageByApplyingGaussianBlurWithSigma:sigma];
|
|
1450
|
+
const CGRect box = CGRectMake(0, 0, (CGFloat)width, (CGFloat)height);
|
|
1451
|
+
|
|
1452
|
+
// One context for the process: building a CIContext is the expensive part of
|
|
1453
|
+
// Core Image, not running the filter.
|
|
1454
|
+
//
|
|
1455
|
+
// Colour management off, which is the difference between this looking like the
|
|
1456
|
+
// other two platforms and not. Core Image converts to linear light by default
|
|
1457
|
+
// and blurs there; GSK blurs the encoded pixels, and React Native's iOS path
|
|
1458
|
+
// runs vImageBoxConvolve over 8-bit sRGB bytes. Measured on a black-to-white
|
|
1459
|
+
// edge, the managed blur spreads about 1.2 times as far as either. Unmanaged,
|
|
1460
|
+
// the filter works on the sample values as given, like both of them.
|
|
1461
|
+
static CIContext *renderer = nil;
|
|
1462
|
+
static dispatch_once_t once;
|
|
1463
|
+
dispatch_once(&once, ^{
|
|
1464
|
+
renderer = [CIContext contextWithOptions:@{kCIContextWorkingColorSpace : (id)[NSNull null]}];
|
|
1465
|
+
});
|
|
1466
|
+
return [renderer createCGImage:blurred fromRect:box];
|
|
1467
|
+
}
|
|
1468
|
+
|
|
584
1469
|
// Where the image lands inside the frame.
|
|
585
1470
|
//
|
|
586
1471
|
// The same arithmetic as the GTK side's, deliberately -- two platforms
|
|
@@ -622,11 +1507,131 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
622
1507
|
}
|
|
623
1508
|
|
|
624
1509
|
- (void)setRnTextLayout:(id)layout {
|
|
625
|
-
|
|
1510
|
+
// A selection survives a rebuild of the same text and not a change to it.
|
|
1511
|
+
// Every mutation that touches a paragraph builds a new layout here -- a
|
|
1512
|
+
// parent re-rendering is enough -- and dropping the selection each time would
|
|
1513
|
+
// make it impossible to keep one in a live app; keeping it across *different*
|
|
1514
|
+
// text would highlight whatever now sits at those offsets. The GTK side draws
|
|
1515
|
+
// the same line in rn_view_set_text_layout.
|
|
1516
|
+
RnTextLayout *replacement = (RnTextLayout *)layout;
|
|
1517
|
+
const BOOL sameText = _textLayout != nil && replacement != nil &&
|
|
1518
|
+
[_textLayout.attributedString.string isEqualToString:replacement.attributedString.string];
|
|
1519
|
+
const NSRange carried = sameText ? _textLayout.selection : NSMakeRange(0, 0);
|
|
1520
|
+
|
|
1521
|
+
_textLayout = replacement;
|
|
1522
|
+
if (_textLayout != nil) {
|
|
1523
|
+
_textLayout.selection = carried;
|
|
1524
|
+
}
|
|
626
1525
|
// A layer-backed view with a drawRect: gets its contents from that draw, and
|
|
627
1526
|
// only redraws when told. Without this the first paragraph appears and no
|
|
628
1527
|
// later one ever does.
|
|
629
1528
|
self.needsDisplay = YES;
|
|
1529
|
+
// A new layout can be a new text height, and a bottom- or middle-aligned
|
|
1530
|
+
// paragraph then sits somewhere else -- so the views inline in it do too.
|
|
1531
|
+
[self rnPlaceInlineChildren];
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
- (void)setRnTextSelectable:(BOOL)selectable {
|
|
1535
|
+
if (_textSelectable == selectable) {
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
_textSelectable = selectable;
|
|
1539
|
+
// A paragraph that stops being selectable keeps no highlight: the prop going
|
|
1540
|
+
// false is an app saying this text is not to be selected, and leaving the
|
|
1541
|
+
// last selection drawn would be the one state nothing can clear.
|
|
1542
|
+
if (!selectable && _textLayout != nil && _textLayout.selection.length > 0) {
|
|
1543
|
+
_textLayout.selection = NSMakeRange(0, 0);
|
|
1544
|
+
self.needsDisplay = YES;
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
- (BOOL)rnTextSelectable {
|
|
1549
|
+
return _textSelectable;
|
|
1550
|
+
}
|
|
1551
|
+
|
|
1552
|
+
- (CGFloat)rnTextVerticalOffset {
|
|
1553
|
+
if (_textLayout == nil) {
|
|
1554
|
+
return 0;
|
|
1555
|
+
}
|
|
1556
|
+
const CGSize box = self.bounds.size;
|
|
1557
|
+
return [_textLayout verticalOffsetForSize:CGSizeMake(box.width - _rnTextInset.left -
|
|
1558
|
+
_rnTextInset.right,
|
|
1559
|
+
box.height - _rnTextInset.top -
|
|
1560
|
+
_rnTextInset.bottom)];
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1563
|
+
// How far this view's parent moved its text, which is how far this view has to
|
|
1564
|
+
// move with it. Only the vertical alignment: an attachment's frame already
|
|
1565
|
+
// carries the paragraph's padding, React Native having added the content insets
|
|
1566
|
+
// to it in `ParagraphShadowNode::layout`.
|
|
1567
|
+
- (CGFloat)rnParentTextOffset {
|
|
1568
|
+
RnAppKitView *parent = [self.superview isKindOfClass:[RnAppKitView class]]
|
|
1569
|
+
? (RnAppKitView *)self.superview
|
|
1570
|
+
: nil;
|
|
1571
|
+
return parent == nil ? 0 : [parent rnTextVerticalOffset];
|
|
1572
|
+
}
|
|
1573
|
+
|
|
1574
|
+
// Puts every inline view back where this paragraph's text now is. Called when
|
|
1575
|
+
// anything that can move the text changes: the layout itself, and the box.
|
|
1576
|
+
- (void)rnPlaceInlineChildren {
|
|
1577
|
+
if (_textLayout == nil || _textLayout.verticalFlush <= 0) {
|
|
1578
|
+
// Nothing to move, and nothing was: a paragraph at the top of its box
|
|
1579
|
+
// leaves its children where React Native put them.
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
const CGFloat offset = [self rnTextVerticalOffset];
|
|
1583
|
+
for (NSView *child in self.subviews) {
|
|
1584
|
+
if (![child isKindOfClass:[RnAppKitView class]]) {
|
|
1585
|
+
continue;
|
|
1586
|
+
}
|
|
1587
|
+
RnAppKitView *inlineChild = (RnAppKitView *)child;
|
|
1588
|
+
const NSRect frame = inlineChild.frame;
|
|
1589
|
+
const CGFloat wanted = inlineChild->_rnOrigin.y + offset;
|
|
1590
|
+
if (frame.origin.y != wanted) {
|
|
1591
|
+
inlineChild.frame =
|
|
1592
|
+
NSMakeRect(frame.origin.x, wanted, frame.size.width, frame.size.height);
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
- (NSInteger)rnTextIndexAtPoint:(CGPoint)point {
|
|
1598
|
+
if (_textLayout == nil) {
|
|
1599
|
+
return -1;
|
|
1600
|
+
}
|
|
1601
|
+
// In the paragraph's own coordinates: the view's, minus its padding. The same
|
|
1602
|
+
// move the draw makes, or a drag would select the character beside the one
|
|
1603
|
+
// under the pointer.
|
|
1604
|
+
const CGSize box = self.bounds.size;
|
|
1605
|
+
const CGPoint inText =
|
|
1606
|
+
CGPointMake(point.x - _rnTextInset.left, point.y - _rnTextInset.top);
|
|
1607
|
+
const CGSize content = CGSizeMake(box.width - _rnTextInset.left - _rnTextInset.right,
|
|
1608
|
+
box.height - _rnTextInset.top - _rnTextInset.bottom);
|
|
1609
|
+
return (NSInteger)[_textLayout characterIndexAtPoint:inText size:content];
|
|
1610
|
+
}
|
|
1611
|
+
|
|
1612
|
+
- (void)setRnTextSelectionStart:(NSInteger)start length:(NSInteger)length {
|
|
1613
|
+
if (_textLayout == nil) {
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
const NSRange range =
|
|
1617
|
+
NSMakeRange((NSUInteger)MAX((NSInteger)0, start), (NSUInteger)MAX((NSInteger)0, length));
|
|
1618
|
+
if (NSEqualRanges(_textLayout.selection, range)) {
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1621
|
+
_textLayout.selection = range;
|
|
1622
|
+
self.needsDisplay = YES;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
- (NSInteger)rnTextSelectionStart {
|
|
1626
|
+
return _textLayout == nil ? 0 : (NSInteger)_textLayout.selection.location;
|
|
1627
|
+
}
|
|
1628
|
+
|
|
1629
|
+
- (NSInteger)rnTextSelectionLength {
|
|
1630
|
+
return _textLayout == nil ? 0 : (NSInteger)_textLayout.selection.length;
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
- (nullable NSString *)rnSelectedText {
|
|
1634
|
+
return _textLayout == nil ? nil : [_textLayout selectedText];
|
|
630
1635
|
}
|
|
631
1636
|
|
|
632
1637
|
// Only views with text draw anything; the rest are pure CALayer properties and
|
|
@@ -651,14 +1656,39 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
651
1656
|
(void)dirtyRect;
|
|
652
1657
|
const BOOL ring = [self rnShowsFocusRing];
|
|
653
1658
|
if (_textLayout == nil && _image == nullptr && !_hasBorders && !ring &&
|
|
654
|
-
_highlightFilled.empty()) {
|
|
1659
|
+
_highlightFilled.empty() && _gradients.empty()) {
|
|
655
1660
|
return;
|
|
656
1661
|
}
|
|
657
1662
|
CGContextRef context = [NSGraphicsContext currentContext].CGContext;
|
|
658
1663
|
const NSSize size = self.bounds.size;
|
|
659
1664
|
|
|
1665
|
+
// The background image, under everything this view draws and over the
|
|
1666
|
+
// background colour, which is a layer property and so is painted below all of
|
|
1667
|
+
// this. That is CSS's order.
|
|
1668
|
+
[self rnDrawGradientsInContext:context size:size];
|
|
1669
|
+
|
|
660
1670
|
if (_image != nullptr) {
|
|
661
1671
|
const NSRect destination = [self rnImageRectForSize:size];
|
|
1672
|
+
// A blurred copy stands in for the image below, including as the tint's
|
|
1673
|
+
// mask: blurring an alpha mask and filling it with one solid colour is the
|
|
1674
|
+
// same picture as blurring the fill, because the colour is constant. That
|
|
1675
|
+
// is what makes this match the GTK side, which pushes its blur around the
|
|
1676
|
+
// tint rather than around the silhouette it is masked from.
|
|
1677
|
+
//
|
|
1678
|
+
// Made at the destination's size, which for `repeat` is one tile, so every
|
|
1679
|
+
// tile blurs alike as it does on GTK. The device scale comes from the
|
|
1680
|
+
// context rather than from the window, because a view drawn into a bitmap
|
|
1681
|
+
// -- a test, a snapshot -- has no window to ask.
|
|
1682
|
+
CGImageRef pixels = _image;
|
|
1683
|
+
if (_imageBlur > 0) {
|
|
1684
|
+
const CGFloat deviceScale =
|
|
1685
|
+
CGContextConvertSizeToDeviceSpace(context, CGSizeMake(1, 1)).width;
|
|
1686
|
+
CGImageRef blurred = [self rnBlurredImageForSize:destination.size
|
|
1687
|
+
scale:deviceScale > 0 ? deviceScale : 1];
|
|
1688
|
+
if (blurred != nullptr) {
|
|
1689
|
+
pixels = blurred;
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
662
1692
|
|
|
663
1693
|
CGContextSaveGState(context);
|
|
664
1694
|
// cover, center and repeat can all put pixels outside the frame, and an
|
|
@@ -689,21 +1719,21 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
689
1719
|
destination.size.width,
|
|
690
1720
|
destination.size.height);
|
|
691
1721
|
if (_imageTint != nil) {
|
|
692
|
-
CGContextClipToMask(context, tile,
|
|
1722
|
+
CGContextClipToMask(context, tile, pixels);
|
|
693
1723
|
CGContextSetFillColorWithColor(context, _imageTint.CGColor);
|
|
694
1724
|
CGContextFillRect(context, NSMakeRect(0, 0, size.width, size.height));
|
|
695
1725
|
} else {
|
|
696
|
-
CGContextDrawTiledImage(context, tile,
|
|
1726
|
+
CGContextDrawTiledImage(context, tile, pixels);
|
|
697
1727
|
}
|
|
698
1728
|
} else if (_imageTint != nil) {
|
|
699
1729
|
// The image becomes a stencil and the colour is what is drawn. Clipping
|
|
700
1730
|
// to the mask uses the image's alpha, which is what `tintColor` means:
|
|
701
1731
|
// recolour the silhouette rather than blend with the pixels.
|
|
702
|
-
CGContextClipToMask(context, drawn,
|
|
1732
|
+
CGContextClipToMask(context, drawn, pixels);
|
|
703
1733
|
CGContextSetFillColorWithColor(context, _imageTint.CGColor);
|
|
704
1734
|
CGContextFillRect(context, drawn);
|
|
705
1735
|
} else {
|
|
706
|
-
CGContextDrawImage(context, drawn,
|
|
1736
|
+
CGContextDrawImage(context, drawn, pixels);
|
|
707
1737
|
}
|
|
708
1738
|
CGContextRestoreGState(context);
|
|
709
1739
|
}
|
|
@@ -711,7 +1741,16 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
711
1741
|
// Text sits above the image and below any children, which is the order the
|
|
712
1742
|
// GTK side paints in too.
|
|
713
1743
|
if (_textLayout != nil) {
|
|
714
|
-
|
|
1744
|
+
// Inside the padding, in a box the size of the content. See rnTextInset:
|
|
1745
|
+
// the layout draws in a box with a top-left origin, so moving the origin is
|
|
1746
|
+
// the whole of it, and the selection highlight moves with the glyphs
|
|
1747
|
+
// because it is drawn in the same call.
|
|
1748
|
+
const CGSize content = CGSizeMake(size.width - _rnTextInset.left - _rnTextInset.right,
|
|
1749
|
+
size.height - _rnTextInset.top - _rnTextInset.bottom);
|
|
1750
|
+
CGContextSaveGState(context);
|
|
1751
|
+
CGContextTranslateCTM(context, _rnTextInset.left, _rnTextInset.top);
|
|
1752
|
+
[_textLayout drawInContext:context size:content];
|
|
1753
|
+
CGContextRestoreGState(context);
|
|
715
1754
|
}
|
|
716
1755
|
|
|
717
1756
|
// Borders paint over the content, as they do on every other platform.
|
|
@@ -775,6 +1814,57 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
775
1814
|
CGPathRelease(path);
|
|
776
1815
|
}
|
|
777
1816
|
|
|
1817
|
+
// A dotted or dashed border: one stroked path instead of four filled edges.
|
|
1818
|
+
//
|
|
1819
|
+
// The same shape of drawing as the GTK side's, down to the dash pattern, because
|
|
1820
|
+
// a dashed border that started its dashes in a different place on each desktop
|
|
1821
|
+
// would be a difference nobody asked for. The top edge's width and colour decide
|
|
1822
|
+
// the stroke: a stroked path has one pen.
|
|
1823
|
+
- (void)rnStrokeDashedBorderInContext:(CGContextRef)context size:(NSSize)size {
|
|
1824
|
+
const CGFloat width = _borderWidths[0] > 0 ? _borderWidths[0] : 1.0;
|
|
1825
|
+
|
|
1826
|
+
// Inset by half the width, because a stroke straddles its path while the
|
|
1827
|
+
// filled edges sit inside the box. Without that a 4pt dashed border would
|
|
1828
|
+
// paint two points outside the view and over its neighbour.
|
|
1829
|
+
//
|
|
1830
|
+
// The corner radii are left as they are rather than shrunk by the inset, which
|
|
1831
|
+
// is what GSK's own inset does on the other host: it pulls the corners in by
|
|
1832
|
+
// about a third of the inset, and matching it keeps the two drawing one shape.
|
|
1833
|
+
const CGRect centred = CGRectInset(CGRectMake(0, 0, size.width, size.height),
|
|
1834
|
+
width / 2.0, width / 2.0);
|
|
1835
|
+
if (centred.size.width <= 0 || centred.size.height <= 0) {
|
|
1836
|
+
return;
|
|
1837
|
+
}
|
|
1838
|
+
CGPathRef path = RnAppKitCreateRoundedPath(centred, _borderRadii);
|
|
1839
|
+
|
|
1840
|
+
CGContextSaveGState(context);
|
|
1841
|
+
CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
|
1842
|
+
const CGFloat components[4] = {_borderColors[0], _borderColors[1], _borderColors[2],
|
|
1843
|
+
_borderColors[3]};
|
|
1844
|
+
CGColorRef color = CGColorCreate(space, components);
|
|
1845
|
+
CGContextSetStrokeColorWithColor(context, color);
|
|
1846
|
+
CGContextSetLineWidth(context, width);
|
|
1847
|
+
|
|
1848
|
+
// Scaled to the width, the way a browser does it. Dotted is round caps on a
|
|
1849
|
+
// zero-length dash, which is what makes a dot round rather than a short dash.
|
|
1850
|
+
if (_borderStyle == RnAppKitBorderStyleDotted) {
|
|
1851
|
+
const CGFloat dots[2] = {0, width * 2.0};
|
|
1852
|
+
CGContextSetLineCap(context, kCGLineCapRound);
|
|
1853
|
+
CGContextSetLineDash(context, 0, dots, 2);
|
|
1854
|
+
} else {
|
|
1855
|
+
const CGFloat dashes[2] = {width * 3.0, width * 2.0};
|
|
1856
|
+
CGContextSetLineDash(context, 0, dashes, 2);
|
|
1857
|
+
}
|
|
1858
|
+
|
|
1859
|
+
CGContextAddPath(context, path);
|
|
1860
|
+
CGContextStrokePath(context);
|
|
1861
|
+
|
|
1862
|
+
CGColorRelease(color);
|
|
1863
|
+
CGColorSpaceRelease(space);
|
|
1864
|
+
CGContextRestoreGState(context);
|
|
1865
|
+
CGPathRelease(path);
|
|
1866
|
+
}
|
|
1867
|
+
|
|
778
1868
|
// The four edges, each its own width and colour.
|
|
779
1869
|
//
|
|
780
1870
|
// This is CSS's own algorithm rather than a stroke: an edge is the region
|
|
@@ -784,6 +1874,10 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
784
1874
|
// cannot do it -- which matters as soon as one edge differs, and is invisible
|
|
785
1875
|
// until then.
|
|
786
1876
|
- (void)rnDrawBordersInContext:(CGContextRef)context size:(NSSize)size {
|
|
1877
|
+
if (_borderStyle != RnAppKitBorderStyleSolid) {
|
|
1878
|
+
[self rnStrokeDashedBorderInContext:context size:size];
|
|
1879
|
+
return;
|
|
1880
|
+
}
|
|
787
1881
|
const CGFloat w = size.width;
|
|
788
1882
|
const CGFloat h = size.height;
|
|
789
1883
|
const CGFloat top = _borderWidths[0];
|
|
@@ -874,7 +1968,13 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
874
1968
|
|
|
875
1969
|
- (void)setRnOpacity:(CGFloat)opacity {
|
|
876
1970
|
_opacity = opacity;
|
|
877
|
-
self
|
|
1971
|
+
[self rnApplyOpacity];
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1974
|
+
// The view's own opacity and any `opacity()` in its filter list, which CSS lets
|
|
1975
|
+
// an app ask for in both places at once.
|
|
1976
|
+
- (void)rnApplyOpacity {
|
|
1977
|
+
self.layer.opacity = (float)(_opacity * _filterOpacity);
|
|
878
1978
|
}
|
|
879
1979
|
|
|
880
1980
|
- (void)setRnHidesBackFace:(BOOL)hides {
|
|
@@ -945,6 +2045,15 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
945
2045
|
- (void)setRnClipsChildren:(BOOL)clips {
|
|
946
2046
|
_clipsChildren = clips;
|
|
947
2047
|
self.layer.masksToBounds = clips;
|
|
2048
|
+
// Which decides where an outset shadow and an outline live, so both move with
|
|
2049
|
+
// it.
|
|
2050
|
+
if (!_boxShadows.empty()) {
|
|
2051
|
+
[self rnRebuildBoxShadowLayers];
|
|
2052
|
+
[self rnRebuildFilterShadowLayers];
|
|
2053
|
+
}
|
|
2054
|
+
if (_outlineWidth > 0) {
|
|
2055
|
+
[self rnRebuildOutlineLayer];
|
|
2056
|
+
}
|
|
948
2057
|
}
|
|
949
2058
|
|
|
950
2059
|
- (void)setRnZIndex:(NSInteger)zIndex {
|
|
@@ -1089,6 +2198,15 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
1089
2198
|
}
|
|
1090
2199
|
// The border is drawn along these radii.
|
|
1091
2200
|
self.needsDisplay = YES;
|
|
2201
|
+
// And so is every shadow: a shadow follows the box it is cast by, and so does
|
|
2202
|
+
// the outline.
|
|
2203
|
+
if (!_boxShadows.empty()) {
|
|
2204
|
+
[self rnRebuildBoxShadowLayers];
|
|
2205
|
+
[self rnRebuildFilterShadowLayers];
|
|
2206
|
+
}
|
|
2207
|
+
if (_outlineWidth > 0) {
|
|
2208
|
+
[self rnRebuildOutlineLayer];
|
|
2209
|
+
}
|
|
1092
2210
|
}
|
|
1093
2211
|
|
|
1094
2212
|
- (void)rnUpdateRadiusMask {
|
|
@@ -1123,14 +2241,843 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
1123
2241
|
self.needsDisplay = YES;
|
|
1124
2242
|
}
|
|
1125
2243
|
|
|
2244
|
+
// The NSCursor for a CSS cursor keyword, or nil for one macOS has no cursor for.
|
|
2245
|
+
//
|
|
2246
|
+
// The mapping React Native's own macOS fork makes, which is worth matching to the
|
|
2247
|
+
// letter: an app moved from react-native-macos to this platform should get the
|
|
2248
|
+
// same pointer over the same view. That fork is also where the holes come from --
|
|
2249
|
+
// "all-scroll", "cell", "help", "move", "progress" and "wait" have no NSCursor,
|
|
2250
|
+
// public or otherwise, and nil here means the view installs no cursor rect, so
|
|
2251
|
+
// the cursor is inherited rather than forced back to an arrow.
|
|
2252
|
+
//
|
|
2253
|
+
// The resize family is the one place macOS 15 made this better: before it, the
|
|
2254
|
+
// only resize cursors were the four axis-aligned ones, so a corner handle had
|
|
2255
|
+
// nothing to show. `frameResizeCursorFromPosition:inDirections:` has the
|
|
2256
|
+
// diagonals, and the fallbacks below are what the older system can do.
|
|
2257
|
+
static NSCursor *RnAppKitCursorNamed(NSString *name) {
|
|
2258
|
+
if ([name isEqualToString:@"default"]) {
|
|
2259
|
+
return NSCursor.arrowCursor;
|
|
2260
|
+
}
|
|
2261
|
+
if ([name isEqualToString:@"pointer"]) {
|
|
2262
|
+
return NSCursor.pointingHandCursor;
|
|
2263
|
+
}
|
|
2264
|
+
if ([name isEqualToString:@"text"]) {
|
|
2265
|
+
return NSCursor.IBeamCursor;
|
|
2266
|
+
}
|
|
2267
|
+
if ([name isEqualToString:@"crosshair"]) {
|
|
2268
|
+
return NSCursor.crosshairCursor;
|
|
2269
|
+
}
|
|
2270
|
+
if ([name isEqualToString:@"grab"]) {
|
|
2271
|
+
return NSCursor.openHandCursor;
|
|
2272
|
+
}
|
|
2273
|
+
if ([name isEqualToString:@"grabbing"]) {
|
|
2274
|
+
return NSCursor.closedHandCursor;
|
|
2275
|
+
}
|
|
2276
|
+
if ([name isEqualToString:@"alias"]) {
|
|
2277
|
+
return NSCursor.dragLinkCursor;
|
|
2278
|
+
}
|
|
2279
|
+
if ([name isEqualToString:@"copy"]) {
|
|
2280
|
+
return NSCursor.dragCopyCursor;
|
|
2281
|
+
}
|
|
2282
|
+
if ([name isEqualToString:@"context-menu"]) {
|
|
2283
|
+
return NSCursor.contextualMenuCursor;
|
|
2284
|
+
}
|
|
2285
|
+
// CSS distinguishes "this is not a drop target" from "this is not allowed at
|
|
2286
|
+
// all"; macOS has one cursor for both, as does react-native-macos.
|
|
2287
|
+
if ([name isEqualToString:@"no-drop"] || [name isEqualToString:@"not-allowed"]) {
|
|
2288
|
+
return NSCursor.operationNotAllowedCursor;
|
|
2289
|
+
}
|
|
2290
|
+
// An empty one-pixel image, which is how macOS hides the pointer over a view.
|
|
2291
|
+
if ([name isEqualToString:@"none"]) {
|
|
2292
|
+
static NSCursor *blank = nil;
|
|
2293
|
+
static dispatch_once_t once;
|
|
2294
|
+
dispatch_once(&once, ^{
|
|
2295
|
+
blank = [[NSCursor alloc] initWithImage:[[NSImage alloc] initWithSize:NSMakeSize(1, 1)]
|
|
2296
|
+
hotSpot:NSZeroPoint];
|
|
2297
|
+
});
|
|
2298
|
+
return blank;
|
|
2299
|
+
}
|
|
2300
|
+
if ([name isEqualToString:@"ew-resize"] || [name isEqualToString:@"e-resize"] ||
|
|
2301
|
+
[name isEqualToString:@"w-resize"]) {
|
|
2302
|
+
return NSCursor.resizeLeftRightCursor;
|
|
2303
|
+
}
|
|
2304
|
+
if ([name isEqualToString:@"ns-resize"] || [name isEqualToString:@"n-resize"] ||
|
|
2305
|
+
[name isEqualToString:@"s-resize"]) {
|
|
2306
|
+
return NSCursor.resizeUpDownCursor;
|
|
2307
|
+
}
|
|
2308
|
+
if ([name isEqualToString:@"col-resize"]) {
|
|
2309
|
+
if (@available(macOS 15.0, *)) {
|
|
2310
|
+
return NSCursor.columnResizeCursor;
|
|
2311
|
+
}
|
|
2312
|
+
return NSCursor.resizeLeftRightCursor;
|
|
2313
|
+
}
|
|
2314
|
+
if ([name isEqualToString:@"row-resize"]) {
|
|
2315
|
+
if (@available(macOS 15.0, *)) {
|
|
2316
|
+
return NSCursor.rowResizeCursor;
|
|
2317
|
+
}
|
|
2318
|
+
return NSCursor.resizeUpDownCursor;
|
|
2319
|
+
}
|
|
2320
|
+
if (@available(macOS 15.0, *)) {
|
|
2321
|
+
// The diagonals, which only exist as frame-resize cursors. "nesw" and "nwse"
|
|
2322
|
+
// are the two-headed pair and the single directions are one-headed, which is
|
|
2323
|
+
// what Outward and All mean here.
|
|
2324
|
+
if ([name isEqualToString:@"nesw-resize"]) {
|
|
2325
|
+
return [NSCursor frameResizeCursorFromPosition:NSCursorFrameResizePositionTopRight
|
|
2326
|
+
inDirections:NSCursorFrameResizeDirectionsAll];
|
|
2327
|
+
}
|
|
2328
|
+
if ([name isEqualToString:@"nwse-resize"]) {
|
|
2329
|
+
return [NSCursor frameResizeCursorFromPosition:NSCursorFrameResizePositionTopLeft
|
|
2330
|
+
inDirections:NSCursorFrameResizeDirectionsAll];
|
|
2331
|
+
}
|
|
2332
|
+
if ([name isEqualToString:@"ne-resize"]) {
|
|
2333
|
+
return [NSCursor frameResizeCursorFromPosition:NSCursorFrameResizePositionTopRight
|
|
2334
|
+
inDirections:NSCursorFrameResizeDirectionsOutward];
|
|
2335
|
+
}
|
|
2336
|
+
if ([name isEqualToString:@"nw-resize"]) {
|
|
2337
|
+
return [NSCursor frameResizeCursorFromPosition:NSCursorFrameResizePositionTopLeft
|
|
2338
|
+
inDirections:NSCursorFrameResizeDirectionsOutward];
|
|
2339
|
+
}
|
|
2340
|
+
if ([name isEqualToString:@"se-resize"]) {
|
|
2341
|
+
return [NSCursor frameResizeCursorFromPosition:NSCursorFrameResizePositionBottomRight
|
|
2342
|
+
inDirections:NSCursorFrameResizeDirectionsOutward];
|
|
2343
|
+
}
|
|
2344
|
+
if ([name isEqualToString:@"sw-resize"]) {
|
|
2345
|
+
return [NSCursor frameResizeCursorFromPosition:NSCursorFrameResizePositionBottomLeft
|
|
2346
|
+
inDirections:NSCursorFrameResizeDirectionsOutward];
|
|
2347
|
+
}
|
|
2348
|
+
if ([name isEqualToString:@"zoom-in"]) {
|
|
2349
|
+
return NSCursor.zoomInCursor;
|
|
2350
|
+
}
|
|
2351
|
+
if ([name isEqualToString:@"zoom-out"]) {
|
|
2352
|
+
return NSCursor.zoomOutCursor;
|
|
2353
|
+
}
|
|
2354
|
+
}
|
|
2355
|
+
return nil;
|
|
2356
|
+
}
|
|
2357
|
+
|
|
2358
|
+
- (void)setRnCursorName:(NSString *)name {
|
|
2359
|
+
NSString *wanted = name.length > 0 ? name : nil;
|
|
2360
|
+
if (_cursorName == wanted || [_cursorName isEqualToString:wanted]) {
|
|
2361
|
+
return;
|
|
2362
|
+
}
|
|
2363
|
+
_cursorName = [wanted copy];
|
|
2364
|
+
_cursor = wanted != nil ? RnAppKitCursorNamed(wanted) : nil;
|
|
2365
|
+
// Cursor rects are cached by the window and only rebuilt when it is told to,
|
|
2366
|
+
// so a prop that changes while the pointer is already inside the view would
|
|
2367
|
+
// otherwise keep the old cursor until the pointer left and came back.
|
|
2368
|
+
[self.window invalidateCursorRectsForView:self];
|
|
2369
|
+
}
|
|
2370
|
+
|
|
2371
|
+
- (NSCursor *)rnResolvedCursor {
|
|
2372
|
+
return _cursor;
|
|
2373
|
+
}
|
|
2374
|
+
|
|
2375
|
+
// `mixBlendMode`, which on macOS is one property: a Core Image blend-mode filter
|
|
2376
|
+
// on the layer, blending it with whatever is already beneath it.
|
|
2377
|
+
//
|
|
2378
|
+
// Public API here, as `CALayer.filters` is, and private on iOS -- React Native's
|
|
2379
|
+
// own iOS half sets `compositingFilter` to a bare string, which is undocumented
|
|
2380
|
+
// there. This sets the `CIFilter` the property is documented to take.
|
|
2381
|
+
//
|
|
2382
|
+
// The CSS names map one to one, `plus-lighter` included: it is linear dodge,
|
|
2383
|
+
// which is clamped addition, and that is what the keyword means.
|
|
2384
|
+
static NSString *RnAppKitBlendFilterNamed(NSString *keyword) {
|
|
2385
|
+
static NSDictionary<NSString *, NSString *> *names = nil;
|
|
2386
|
+
static dispatch_once_t once;
|
|
2387
|
+
dispatch_once(&once, ^{
|
|
2388
|
+
names = @{
|
|
2389
|
+
@"multiply" : @"CIMultiplyBlendMode",
|
|
2390
|
+
@"screen" : @"CIScreenBlendMode",
|
|
2391
|
+
@"overlay" : @"CIOverlayBlendMode",
|
|
2392
|
+
@"darken" : @"CIDarkenBlendMode",
|
|
2393
|
+
@"lighten" : @"CILightenBlendMode",
|
|
2394
|
+
@"color-dodge" : @"CIColorDodgeBlendMode",
|
|
2395
|
+
@"color-burn" : @"CIColorBurnBlendMode",
|
|
2396
|
+
@"hard-light" : @"CIHardLightBlendMode",
|
|
2397
|
+
@"soft-light" : @"CISoftLightBlendMode",
|
|
2398
|
+
@"difference" : @"CIDifferenceBlendMode",
|
|
2399
|
+
@"exclusion" : @"CIExclusionBlendMode",
|
|
2400
|
+
@"hue" : @"CIHueBlendMode",
|
|
2401
|
+
@"saturation" : @"CISaturationBlendMode",
|
|
2402
|
+
@"color" : @"CIColorBlendMode",
|
|
2403
|
+
@"luminosity" : @"CILuminosityBlendMode",
|
|
2404
|
+
@"plus-lighter" : @"CILinearDodgeBlendMode",
|
|
2405
|
+
};
|
|
2406
|
+
});
|
|
2407
|
+
return names[keyword];
|
|
2408
|
+
}
|
|
2409
|
+
|
|
2410
|
+
- (void)setRnBlendModeName:(NSString *)name {
|
|
2411
|
+
NSString *wanted = name.length > 0 ? name : nil;
|
|
2412
|
+
if (_blendModeName == wanted || [_blendModeName isEqualToString:wanted]) {
|
|
2413
|
+
return;
|
|
2414
|
+
}
|
|
2415
|
+
_blendModeName = [wanted copy];
|
|
2416
|
+
_blendFilterName = wanted != nil ? RnAppKitBlendFilterNamed(wanted) : nil;
|
|
2417
|
+
// A keyword Core Image has no filter for leaves the layer unblended rather
|
|
2418
|
+
// than wrongly blended, and the keyword is still reported: the dump says what
|
|
2419
|
+
// the app asked for, which is the rule the cursor name follows too.
|
|
2420
|
+
self.layer.compositingFilter =
|
|
2421
|
+
_blendFilterName != nil ? [CIFilter filterWithName:_blendFilterName] : nil;
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
- (NSString *)rnBlendModeName {
|
|
2425
|
+
return _blendModeName;
|
|
2426
|
+
}
|
|
2427
|
+
|
|
2428
|
+
- (NSString *)rnBlendFilterName {
|
|
2429
|
+
return _blendFilterName;
|
|
2430
|
+
}
|
|
2431
|
+
|
|
2432
|
+
// AppKit asks for the rects rather than being told, which is why the cursor is
|
|
2433
|
+
// held and applied here: one rect over the whole view, discarded and rebuilt
|
|
2434
|
+
// each time, as react-native-macos does it. A child with its own cursor adds its
|
|
2435
|
+
// own rect and wins inside its own bounds, which is what CSS does.
|
|
2436
|
+
- (void)resetCursorRects {
|
|
2437
|
+
[self discardCursorRects];
|
|
2438
|
+
if (_cursor != nil) {
|
|
2439
|
+
[self addCursorRect:self.bounds cursor:_cursor];
|
|
2440
|
+
}
|
|
2441
|
+
}
|
|
2442
|
+
|
|
2443
|
+
- (void)setRnGradients:(const RnAppKitGradient *)gradients count:(NSInteger)count {
|
|
2444
|
+
std::vector<RnAppKitGradientRecord> wanted;
|
|
2445
|
+
if (gradients != nullptr && count > 0) {
|
|
2446
|
+
wanted.reserve((size_t)count);
|
|
2447
|
+
for (NSInteger i = 0; i < count; i++) {
|
|
2448
|
+
RnAppKitGradientRecord record{gradients[i].kind,
|
|
2449
|
+
gradients[i].start,
|
|
2450
|
+
gradients[i].end,
|
|
2451
|
+
gradients[i].center,
|
|
2452
|
+
gradients[i].radiusX,
|
|
2453
|
+
gradients[i].radiusY,
|
|
2454
|
+
gradients[i].area,
|
|
2455
|
+
gradients[i].tile,
|
|
2456
|
+
gradients[i].repeats,
|
|
2457
|
+
{}};
|
|
2458
|
+
if (gradients[i].stops != nullptr && gradients[i].stopCount > 0) {
|
|
2459
|
+
record.stops.assign(gradients[i].stops, gradients[i].stops + gradients[i].stopCount);
|
|
2460
|
+
}
|
|
2461
|
+
wanted.push_back(std::move(record));
|
|
2462
|
+
}
|
|
2463
|
+
}
|
|
2464
|
+
|
|
2465
|
+
// Compared before it is kept, for the reason the shadows are: a view re-sends
|
|
2466
|
+
// identical props on every mutation, and a redraw per mutation is a gradient
|
|
2467
|
+
// rasterised again for nothing.
|
|
2468
|
+
if (wanted.size() == _gradients.size()) {
|
|
2469
|
+
bool same = true;
|
|
2470
|
+
for (size_t i = 0; i < wanted.size() && same; i++) {
|
|
2471
|
+
same = wanted[i].kind == _gradients[i].kind &&
|
|
2472
|
+
CGRectEqualToRect(wanted[i].area, _gradients[i].area) &&
|
|
2473
|
+
CGRectEqualToRect(wanted[i].tile, _gradients[i].tile) &&
|
|
2474
|
+
wanted[i].repeats == _gradients[i].repeats &&
|
|
2475
|
+
CGPointEqualToPoint(wanted[i].start, _gradients[i].start) &&
|
|
2476
|
+
CGPointEqualToPoint(wanted[i].end, _gradients[i].end) &&
|
|
2477
|
+
CGPointEqualToPoint(wanted[i].center, _gradients[i].center) &&
|
|
2478
|
+
wanted[i].radiusX == _gradients[i].radiusX &&
|
|
2479
|
+
wanted[i].radiusY == _gradients[i].radiusY &&
|
|
2480
|
+
wanted[i].stops.size() == _gradients[i].stops.size() &&
|
|
2481
|
+
(wanted[i].stops.empty() ||
|
|
2482
|
+
memcmp(wanted[i].stops.data(),
|
|
2483
|
+
_gradients[i].stops.data(),
|
|
2484
|
+
wanted[i].stops.size() * sizeof(RnAppKitGradientStop)) == 0);
|
|
2485
|
+
}
|
|
2486
|
+
if (same) {
|
|
2487
|
+
return;
|
|
2488
|
+
}
|
|
2489
|
+
}
|
|
2490
|
+
_gradients = std::move(wanted);
|
|
2491
|
+
self.needsDisplay = YES;
|
|
2492
|
+
}
|
|
2493
|
+
|
|
2494
|
+
- (NSInteger)rnGradientCount {
|
|
2495
|
+
return (NSInteger)_gradients.size();
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
// The gradients, back to front, clipped to the view's own rounded box.
|
|
2499
|
+
//
|
|
2500
|
+
// `drawRect:` is already clipped to the bounds, so only the rounded case needs a
|
|
2501
|
+
// path; a gradient with square corners on a rounded card is the kind of
|
|
2502
|
+
// difference a screenshot shows and a tree does not.
|
|
2503
|
+
//
|
|
2504
|
+
// Drawn beyond both ends of its line, which is what CSS does: the first and last
|
|
2505
|
+
// colours extend to the edges of the box rather than leaving it unpainted, and
|
|
2506
|
+
// the gradient line is often shorter than the box's diagonal.
|
|
2507
|
+
- (void)rnDrawGradientsInContext:(CGContextRef)context size:(NSSize)size {
|
|
2508
|
+
if (_gradients.empty()) {
|
|
2509
|
+
return;
|
|
2510
|
+
}
|
|
2511
|
+
CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
|
2512
|
+
for (auto gradient = _gradients.rbegin(); gradient != _gradients.rend(); ++gradient) {
|
|
2513
|
+
if (gradient->stops.empty()) {
|
|
2514
|
+
continue;
|
|
2515
|
+
}
|
|
2516
|
+
std::vector<CGFloat> components;
|
|
2517
|
+
std::vector<CGFloat> locations;
|
|
2518
|
+
components.reserve(gradient->stops.size() * 4);
|
|
2519
|
+
locations.reserve(gradient->stops.size());
|
|
2520
|
+
for (const RnAppKitGradientStop &stop : gradient->stops) {
|
|
2521
|
+
components.insert(components.end(), stop.color, stop.color + 4);
|
|
2522
|
+
locations.push_back(stop.offset);
|
|
2523
|
+
}
|
|
2524
|
+
CGGradientRef ramp = CGGradientCreateWithColorComponents(
|
|
2525
|
+
space, components.data(), locations.data(), locations.size());
|
|
2526
|
+
if (ramp == nullptr) {
|
|
2527
|
+
continue;
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
if (gradient->area.size.width <= 0 || gradient->area.size.height <= 0) {
|
|
2531
|
+
CGGradientRelease(ramp);
|
|
2532
|
+
continue;
|
|
2533
|
+
}
|
|
2534
|
+
|
|
2535
|
+
CGContextSaveGState(context);
|
|
2536
|
+
if (_hasBorderRadii || _cornerRadius > 0) {
|
|
2537
|
+
CGFloat radii[8];
|
|
2538
|
+
for (int i = 0; i < 8; i++) {
|
|
2539
|
+
radii[i] = _hasBorderRadii ? _borderRadii[i] : _cornerRadius;
|
|
2540
|
+
}
|
|
2541
|
+
CGPathRef path = RnAppKitCreateRoundedPath(CGRectMake(0, 0, size.width, size.height), radii);
|
|
2542
|
+
CGContextAddPath(context, path);
|
|
2543
|
+
CGContextClip(context);
|
|
2544
|
+
CGPathRelease(path);
|
|
2545
|
+
}
|
|
2546
|
+
|
|
2547
|
+
// Where the tiles go. Core Graphics has no repeat, so this is the loop GSK's
|
|
2548
|
+
// repeat node is on the other host: from the first tile, stepping by the
|
|
2549
|
+
// period until past the painting area. An axis that does not repeat arrives
|
|
2550
|
+
// with the painting area as its tile, so it steps once.
|
|
2551
|
+
const CGRect painting = CGRectMake(0, 0, size.width, size.height);
|
|
2552
|
+
// Nothing repeating is one tile, whatever the tile says: the flag is what
|
|
2553
|
+
// core resolved, and the GTK side reads it the same way by pushing no repeat
|
|
2554
|
+
// node at all. Stepping by a tile that happens to equal the image would draw
|
|
2555
|
+
// a grid for a `no-repeat` background, which is what the test for an image
|
|
2556
|
+
// filling its own rectangle caught.
|
|
2557
|
+
const CGFloat stepX = gradient->repeats && gradient->tile.size.width > 0
|
|
2558
|
+
? gradient->tile.size.width
|
|
2559
|
+
: painting.size.width;
|
|
2560
|
+
const CGFloat stepY = gradient->repeats && gradient->tile.size.height > 0
|
|
2561
|
+
? gradient->tile.size.height
|
|
2562
|
+
: painting.size.height;
|
|
2563
|
+
// Capped, because a stylesheet may ask for a one-point tile over a window:
|
|
2564
|
+
// 256 steps per axis is past the point where another tile changes a pixel on
|
|
2565
|
+
// any screen this runs on, and it keeps a pathological style from costing
|
|
2566
|
+
// minutes a frame. GSK's node has its own bound for the same reason.
|
|
2567
|
+
constexpr int kMaxTiles = 256;
|
|
2568
|
+
const CGFloat originX = gradient->repeats ? gradient->tile.origin.x : gradient->area.origin.x;
|
|
2569
|
+
const CGFloat originY = gradient->repeats ? gradient->tile.origin.y : gradient->area.origin.y;
|
|
2570
|
+
const int countX = gradient->repeats && stepX > 0
|
|
2571
|
+
? (int)std::min<double>(kMaxTiles,
|
|
2572
|
+
std::floor((CGRectGetMaxX(painting) - originX) / stepX) + 1)
|
|
2573
|
+
: 1;
|
|
2574
|
+
const int countY = gradient->repeats && stepY > 0
|
|
2575
|
+
? (int)std::min<double>(kMaxTiles,
|
|
2576
|
+
std::floor((CGRectGetMaxY(painting) - originY) / stepY) + 1)
|
|
2577
|
+
: 1;
|
|
2578
|
+
|
|
2579
|
+
for (int row = 0; row < std::max(countY, 1); row++) {
|
|
2580
|
+
for (int column = 0; column < std::max(countX, 1); column++) {
|
|
2581
|
+
const CGFloat dx = column * stepX;
|
|
2582
|
+
const CGFloat dy = row * stepY;
|
|
2583
|
+
const CGRect imageRect = CGRectOffset(gradient->area, dx, dy);
|
|
2584
|
+
if (!CGRectIntersectsRect(imageRect, painting)) {
|
|
2585
|
+
continue;
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
CGContextSaveGState(context);
|
|
2589
|
+
// Clipped to the image's own rectangle: the gradient is drawn with
|
|
2590
|
+
// `kCGGradientDrawsBeforeStartLocation` and its `After` twin, which fill
|
|
2591
|
+
// the whole clip with the end colours -- so the clip is what keeps one
|
|
2592
|
+
// tile inside its tile.
|
|
2593
|
+
CGContextClipToRect(context, imageRect);
|
|
2594
|
+
CGContextTranslateCTM(context, dx, dy);
|
|
2595
|
+
[self rnDrawOneGradient:*gradient ramp:ramp context:context];
|
|
2596
|
+
CGContextRestoreGState(context);
|
|
2597
|
+
}
|
|
2598
|
+
}
|
|
2599
|
+
CGContextRestoreGState(context);
|
|
2600
|
+
CGGradientRelease(ramp);
|
|
2601
|
+
}
|
|
2602
|
+
CGColorSpaceRelease(space);
|
|
2603
|
+
}
|
|
2604
|
+
|
|
2605
|
+
// One gradient, at the rectangle its geometry was resolved against. The caller
|
|
2606
|
+
// has clipped and translated to the tile.
|
|
2607
|
+
- (void)rnDrawOneGradient:(const RnAppKitGradientRecord &)gradientRef
|
|
2608
|
+
ramp:(CGGradientRef)ramp
|
|
2609
|
+
context:(CGContextRef)context {
|
|
2610
|
+
const RnAppKitGradientRecord *gradient = &gradientRef;
|
|
2611
|
+
if (gradient->kind == RnAppKitGradientKindRadial) {
|
|
2612
|
+
// Core Graphics draws radial gradients between two *circles*, so an ellipse
|
|
2613
|
+
// is a scaled coordinate system rather than a different call: squash the y
|
|
2614
|
+
// axis around the centre by the ratio of the radii and draw a circle of the
|
|
2615
|
+
// horizontal radius. A host that drew a circle of one radius would be
|
|
2616
|
+
// drawing a plausible gradient of the wrong shape.
|
|
2617
|
+
const CGFloat radiusX = gradient->radiusX;
|
|
2618
|
+
const CGFloat radiusY = gradient->radiusY;
|
|
2619
|
+
if (radiusX <= 0 || radiusY <= 0) {
|
|
2620
|
+
// An ending shape with no size. The caller's clip and state are undone by
|
|
2621
|
+
// its own restore.
|
|
2622
|
+
return;
|
|
2623
|
+
}
|
|
2624
|
+
// Scaled *about the centre*, so the centre itself does not move and the
|
|
2625
|
+
// circle of the horizontal radius comes out as an ellipse with the vertical
|
|
2626
|
+
// one. Scaling about the origin instead would need the centre divided by the
|
|
2627
|
+
// same ratio, which is the mistake this comment exists to stop: it draws an
|
|
2628
|
+
// ellipse of the right shape in the wrong place.
|
|
2629
|
+
CGContextTranslateCTM(context, gradient->center.x, gradient->center.y);
|
|
2630
|
+
CGContextScaleCTM(context, 1.0, radiusY / radiusX);
|
|
2631
|
+
CGContextTranslateCTM(context, -gradient->center.x, -gradient->center.y);
|
|
2632
|
+
CGContextDrawRadialGradient(context,
|
|
2633
|
+
ramp,
|
|
2634
|
+
gradient->center,
|
|
2635
|
+
0.0,
|
|
2636
|
+
gradient->center,
|
|
2637
|
+
radiusX,
|
|
2638
|
+
kCGGradientDrawsBeforeStartLocation |
|
|
2639
|
+
kCGGradientDrawsAfterEndLocation);
|
|
2640
|
+
} else {
|
|
2641
|
+
CGContextDrawLinearGradient(context,
|
|
2642
|
+
ramp,
|
|
2643
|
+
gradient->start,
|
|
2644
|
+
gradient->end,
|
|
2645
|
+
kCGGradientDrawsBeforeStartLocation |
|
|
2646
|
+
kCGGradientDrawsAfterEndLocation);
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
|
|
2650
|
+
- (void)setRnBoxShadows:(const RnAppKitBoxShadow *)shadows count:(NSInteger)count {
|
|
2651
|
+
std::vector<RnAppKitBoxShadow> wanted;
|
|
2652
|
+
if (shadows != nullptr && count > 0) {
|
|
2653
|
+
wanted.assign(shadows, shadows + count);
|
|
2654
|
+
}
|
|
2655
|
+
// Compared rather than rebuilt blindly: a view re-sends identical props on
|
|
2656
|
+
// every mutation, and a layer rebuilt per mutation is a shadow recomputed for
|
|
2657
|
+
// nothing on every keystroke.
|
|
2658
|
+
if (wanted.size() == _boxShadows.size() &&
|
|
2659
|
+
(wanted.empty() || memcmp(wanted.data(), _boxShadows.data(),
|
|
2660
|
+
wanted.size() * sizeof(RnAppKitBoxShadow)) == 0)) {
|
|
2661
|
+
return;
|
|
2662
|
+
}
|
|
2663
|
+
_boxShadows = std::move(wanted);
|
|
2664
|
+
[self rnRebuildBoxShadowLayers];
|
|
2665
|
+
[self rnRebuildFilterShadowLayers];
|
|
2666
|
+
}
|
|
2667
|
+
|
|
2668
|
+
- (NSInteger)rnBoxShadowCount {
|
|
2669
|
+
return (NSInteger)_boxShadows.size();
|
|
2670
|
+
}
|
|
2671
|
+
|
|
2672
|
+
- (NSArray<CALayer *> *)rnBoxShadowLayers {
|
|
2673
|
+
return _boxShadowLayers != nil ? [_boxShadowLayers copy] : @[];
|
|
2674
|
+
}
|
|
2675
|
+
|
|
2676
|
+
// One layer per shadow, rebuilt whenever the list, the view's size, its radii or
|
|
2677
|
+
// its place in the tree changes -- a shadow's path is in the view's own
|
|
2678
|
+
// coordinates and an outset one lives in its parent, so all four invalidate it.
|
|
2679
|
+
//
|
|
2680
|
+
// Added back to front, because CSS says the first shadow in the list is the one
|
|
2681
|
+
// on top and sublayers composite in order.
|
|
2682
|
+
//
|
|
2683
|
+
// **Where an outset shadow goes depends on whether this view clips itself.**
|
|
2684
|
+
//
|
|
2685
|
+
// Normally it is a sublayer of the view, which is what React Native's iOS half
|
|
2686
|
+
// does: the mask cuts the box out of the shadow, so it only ever paints outside,
|
|
2687
|
+
// and being inside the view's own layer it composites exactly where the view
|
|
2688
|
+
// does -- above earlier siblings, below later ones.
|
|
2689
|
+
//
|
|
2690
|
+
// That arrangement loses the shadow entirely when the view clips its own layer,
|
|
2691
|
+
// which `overflow: 'hidden'` does through `masksToBounds` and non-uniform radii
|
|
2692
|
+
// do through a mask layer. CSS clips neither: an element's own shadow is outside
|
|
2693
|
+
// its box and is not subject to its overflow. So a clipping view casts its
|
|
2694
|
+
// shadow into the *parent's* layer instead, below its own.
|
|
2695
|
+
//
|
|
2696
|
+
// The cost of that path is z-order among siblings: AppKit attaches a subview's
|
|
2697
|
+
// layer lazily, at the first display, and these are built while mounting -- so
|
|
2698
|
+
// `below:self.layer` has nothing to aim at yet and the shadow lands at the
|
|
2699
|
+
// bottom of the parent, below every sibling rather than below this view alone.
|
|
2700
|
+
// Visible only where a sibling overlaps the shadow of a clipping view, which is
|
|
2701
|
+
// the narrower case of the two.
|
|
2702
|
+
//
|
|
2703
|
+
// Inset shadows are always sublayers of this view, where clipping is harmless
|
|
2704
|
+
// and right. They do paint above this view's own drawn text rather than under
|
|
2705
|
+
// it, which the GTK host gets right for free; backlog/correctness.md records
|
|
2706
|
+
// both of these.
|
|
2707
|
+
// Whether this view's own layer clips what is inside it, which decides where an
|
|
2708
|
+
// outset shadow can live. Both answers come from props: `overflow: 'hidden'` and
|
|
2709
|
+
// a set of radii too elliptical for CALayer's cornerRadius.
|
|
2710
|
+
- (BOOL)rnClipsItsOwnLayer {
|
|
2711
|
+
return _clipsChildren || self.layer.mask != nil;
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
// The drop shadows a CALayer cannot hold, one layer each.
|
|
2715
|
+
//
|
|
2716
|
+
// Rebuilt rather than updated, the way the box shadows are: the geometry
|
|
2717
|
+
// depends on the view's size and corner radii, both of which change under it.
|
|
2718
|
+
- (void)rnRebuildFilterShadowLayers {
|
|
2719
|
+
for (CALayer *layer in _filterShadowLayers) {
|
|
2720
|
+
[layer removeFromSuperlayer];
|
|
2721
|
+
}
|
|
2722
|
+
[_filterShadowLayers removeAllObjects];
|
|
2723
|
+
if (_extraFilterShadows.empty()) {
|
|
2724
|
+
return;
|
|
2725
|
+
}
|
|
2726
|
+
if (_filterShadowLayers == nil) {
|
|
2727
|
+
_filterShadowLayers = [NSMutableArray array];
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
const CGSize size = self.bounds.size;
|
|
2731
|
+
if (size.width <= 0 || size.height <= 0) {
|
|
2732
|
+
return;
|
|
2733
|
+
}
|
|
2734
|
+
CGFloat radii[8];
|
|
2735
|
+
for (int i = 0; i < 8; i++) {
|
|
2736
|
+
radii[i] = _hasBorderRadii ? _borderRadii[i] : 0;
|
|
2737
|
+
}
|
|
2738
|
+
|
|
2739
|
+
// Back to front: the last function in the list is the outermost, so it is
|
|
2740
|
+
// added first and the earlier ones are drawn over it.
|
|
2741
|
+
for (auto shadow = _extraFilterShadows.rbegin(); shadow != _extraFilterShadows.rend();
|
|
2742
|
+
++shadow) {
|
|
2743
|
+
if (shadow->color[3] <= 0) {
|
|
2744
|
+
continue;
|
|
2745
|
+
}
|
|
2746
|
+
CALayer *layer = RnAppKitFilterShadowLayer(*shadow, radii, size);
|
|
2747
|
+
[self.layer insertSublayer:layer atIndex:0];
|
|
2748
|
+
[_filterShadowLayers addObject:layer];
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
- (void)rnRebuildBoxShadowLayers {
|
|
2753
|
+
for (CALayer *layer in _boxShadowLayers) {
|
|
2754
|
+
[layer removeFromSuperlayer];
|
|
2755
|
+
}
|
|
2756
|
+
[_boxShadowLayers removeAllObjects];
|
|
2757
|
+
if (_boxShadows.empty()) {
|
|
2758
|
+
return;
|
|
2759
|
+
}
|
|
2760
|
+
if (_boxShadowLayers == nil) {
|
|
2761
|
+
_boxShadowLayers = [NSMutableArray array];
|
|
2762
|
+
}
|
|
2763
|
+
|
|
2764
|
+
const CGSize size = self.bounds.size;
|
|
2765
|
+
if (size.width <= 0 || size.height <= 0) {
|
|
2766
|
+
return;
|
|
2767
|
+
}
|
|
2768
|
+
CGFloat radii[8];
|
|
2769
|
+
for (int i = 0; i < 8; i++) {
|
|
2770
|
+
radii[i] = _hasBorderRadii ? _borderRadii[i] : 0;
|
|
2771
|
+
}
|
|
2772
|
+
|
|
2773
|
+
for (auto shadow = _boxShadows.rbegin(); shadow != _boxShadows.rend(); ++shadow) {
|
|
2774
|
+
// A shadow React Native could not parse a colour for arrives with none.
|
|
2775
|
+
// Painting it would put a black shadow under a view that asked for nothing.
|
|
2776
|
+
if (shadow->color[3] <= 0) {
|
|
2777
|
+
continue;
|
|
2778
|
+
}
|
|
2779
|
+
if (shadow->inset) {
|
|
2780
|
+
CALayer *layer = RnAppKitInsetShadowLayer(*shadow, radii, size);
|
|
2781
|
+
[self.layer addSublayer:layer];
|
|
2782
|
+
[_boxShadowLayers addObject:layer];
|
|
2783
|
+
continue;
|
|
2784
|
+
}
|
|
2785
|
+
CALayer *layer = RnAppKitOutsetShadowLayer(*shadow, radii, size);
|
|
2786
|
+
if (![self rnClipsItsOwnLayer]) {
|
|
2787
|
+
[self.layer addSublayer:layer];
|
|
2788
|
+
[_boxShadowLayers addObject:layer];
|
|
2789
|
+
continue;
|
|
2790
|
+
}
|
|
2791
|
+
CALayer *parent = self.superview.layer;
|
|
2792
|
+
if (parent == nil) {
|
|
2793
|
+
// A clipping surface root, or one not yet mounted. Nothing to cast onto;
|
|
2794
|
+
// the next move into a superview rebuilds this.
|
|
2795
|
+
continue;
|
|
2796
|
+
}
|
|
2797
|
+
// In the parent's coordinates, which are this view's frame. Both are flipped
|
|
2798
|
+
// the same way, so the frame carries over as it stands.
|
|
2799
|
+
layer.frame = self.frame;
|
|
2800
|
+
[parent insertSublayer:layer below:self.layer];
|
|
2801
|
+
[_boxShadowLayers addObject:layer];
|
|
2802
|
+
}
|
|
2803
|
+
}
|
|
2804
|
+
|
|
2805
|
+
// A view that moves, or is removed, takes its shadows with it. Without this an
|
|
2806
|
+
// unmounted card leaves its shadow behind in the parent, which is the one bug
|
|
2807
|
+
// this arrangement can have that the simpler one cannot.
|
|
2808
|
+
- (void)viewDidMoveToSuperview {
|
|
2809
|
+
[super viewDidMoveToSuperview];
|
|
2810
|
+
if (!_boxShadows.empty()) {
|
|
2811
|
+
[self rnRebuildBoxShadowLayers];
|
|
2812
|
+
[self rnRebuildFilterShadowLayers];
|
|
2813
|
+
}
|
|
2814
|
+
if (_outlineWidth > 0) {
|
|
2815
|
+
[self rnRebuildOutlineLayer];
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
|
|
2819
|
+
- (void)setRnOutlineWidth:(CGFloat)width
|
|
2820
|
+
offset:(CGFloat)offset
|
|
2821
|
+
color:(const CGFloat *)color
|
|
2822
|
+
style:(RnAppKitBorderStyle)style {
|
|
2823
|
+
const CGFloat wanted = width > 0 ? width : 0;
|
|
2824
|
+
CGFloat components[4] = {0, 0, 0, 0};
|
|
2825
|
+
for (int i = 0; i < 4; i++) {
|
|
2826
|
+
components[i] = color != nullptr ? color[i] : 0;
|
|
2827
|
+
}
|
|
2828
|
+
if (_outlineWidth == wanted && _outlineOffset == offset && _outlineStyle == style &&
|
|
2829
|
+
memcmp(_outlineColor, components, sizeof(components)) == 0) {
|
|
2830
|
+
return;
|
|
2831
|
+
}
|
|
2832
|
+
_outlineWidth = wanted;
|
|
2833
|
+
_outlineOffset = offset;
|
|
2834
|
+
memcpy(_outlineColor, components, sizeof(components));
|
|
2835
|
+
_outlineStyle = style;
|
|
2836
|
+
[self rnRebuildOutlineLayer];
|
|
2837
|
+
}
|
|
2838
|
+
|
|
2839
|
+
- (CAShapeLayer *)rnOutlineLayer {
|
|
2840
|
+
return _outlineLayer;
|
|
2841
|
+
}
|
|
2842
|
+
|
|
2843
|
+
// The outline, as a stroked shape outside the box.
|
|
2844
|
+
//
|
|
2845
|
+
// Stroked rather than a layer border, which is what React Native's iOS half uses
|
|
2846
|
+
// for the solid case: a stroke takes a dash pattern, so dotted and dashed need no
|
|
2847
|
+
// second mechanism, and it takes an arbitrary path, so elliptical radii need no
|
|
2848
|
+
// special case either. The path sits half a width outside the offset, a stroke
|
|
2849
|
+
// straddling its path.
|
|
2850
|
+
//
|
|
2851
|
+
// Placed by the same rule the box shadows use -- inside the view, or in the
|
|
2852
|
+
// parent when the view clips its own layer -- so an outline on a card with
|
|
2853
|
+
// `overflow: 'hidden'` is not quietly clipped away.
|
|
2854
|
+
- (void)rnRebuildOutlineLayer {
|
|
2855
|
+
[_outlineLayer removeFromSuperlayer];
|
|
2856
|
+
_outlineLayer = nil;
|
|
2857
|
+
if (_outlineWidth <= 0 || _outlineColor[3] <= 0) {
|
|
2858
|
+
return;
|
|
2859
|
+
}
|
|
2860
|
+
const NSRect bounds = self.bounds;
|
|
2861
|
+
if (bounds.size.width <= 0 || bounds.size.height <= 0) {
|
|
2862
|
+
return;
|
|
2863
|
+
}
|
|
2864
|
+
|
|
2865
|
+
const CGFloat grow = _outlineOffset + _outlineWidth / 2;
|
|
2866
|
+
CGFloat radii[8];
|
|
2867
|
+
for (int i = 0; i < 8; i++) {
|
|
2868
|
+
// Each non-zero radius grows with the ring so it stays concentric; a corner
|
|
2869
|
+
// that was square stays square, as it does on the GTK side.
|
|
2870
|
+
radii[i] = _hasBorderRadii ? (_borderRadii[i] > 0 ? _borderRadii[i] + grow : 0)
|
|
2871
|
+
: (_cornerRadius > 0 ? _cornerRadius + grow : 0);
|
|
2872
|
+
}
|
|
2873
|
+
CGPathRef path =
|
|
2874
|
+
RnAppKitCreateRoundedPath(CGRectInset(bounds, -grow, -grow), radii);
|
|
2875
|
+
|
|
2876
|
+
CAShapeLayer *layer = [CAShapeLayer layer];
|
|
2877
|
+
layer.path = path;
|
|
2878
|
+
layer.fillColor = nil;
|
|
2879
|
+
layer.lineWidth = _outlineWidth;
|
|
2880
|
+
CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
|
2881
|
+
CGColorRef colour = CGColorCreate(space, _outlineColor);
|
|
2882
|
+
layer.strokeColor = colour;
|
|
2883
|
+
CGColorRelease(colour);
|
|
2884
|
+
CGColorSpaceRelease(space);
|
|
2885
|
+
|
|
2886
|
+
if (_outlineStyle == RnAppKitBorderStyleDotted) {
|
|
2887
|
+
layer.lineCap = kCALineCapRound;
|
|
2888
|
+
layer.lineDashPattern = @[ @0, @(_outlineWidth * 2) ];
|
|
2889
|
+
} else if (_outlineStyle == RnAppKitBorderStyleDashed) {
|
|
2890
|
+
layer.lineDashPattern = @[ @(_outlineWidth * 3), @(_outlineWidth * 2) ];
|
|
2891
|
+
}
|
|
2892
|
+
|
|
2893
|
+
if ([self rnClipsItsOwnLayer]) {
|
|
2894
|
+
CALayer *parent = self.superview.layer;
|
|
2895
|
+
if (parent != nil) {
|
|
2896
|
+
layer.frame = self.frame;
|
|
2897
|
+
[parent insertSublayer:layer below:self.layer];
|
|
2898
|
+
_outlineLayer = layer;
|
|
2899
|
+
}
|
|
2900
|
+
} else {
|
|
2901
|
+
layer.frame = bounds;
|
|
2902
|
+
[self.layer addSublayer:layer];
|
|
2903
|
+
_outlineLayer = layer;
|
|
2904
|
+
}
|
|
2905
|
+
CGPathRelease(path);
|
|
2906
|
+
}
|
|
2907
|
+
|
|
2908
|
+
- (void)setRnFilters:(const RnAppKitFilters *)filters {
|
|
2909
|
+
if (filters == nullptr) {
|
|
2910
|
+
// Every half has to come off, and there are three. A list of nothing but
|
|
2911
|
+
// `opacity()` leaves no Core Image filter behind, and so does a list of
|
|
2912
|
+
// nothing but `dropShadow()` -- so an early return on `_filters` alone keeps
|
|
2913
|
+
// the view faded, or shadowed, for good. The first of those two was caught
|
|
2914
|
+
// by the test for taking a filter away; the second was caught by the same
|
|
2915
|
+
// test written again for the shadow, which is the argument for writing it.
|
|
2916
|
+
if (_filters == nil && _filterOpacity == 1 && _filterShadow.color[3] == 0 &&
|
|
2917
|
+
_filterShadow.standardDeviation == 0 && _extraFilterShadows.empty()) {
|
|
2918
|
+
return;
|
|
2919
|
+
}
|
|
2920
|
+
_filters = nil;
|
|
2921
|
+
self.layer.filters = nil;
|
|
2922
|
+
_filterOpacity = 1;
|
|
2923
|
+
[self rnApplyOpacity];
|
|
2924
|
+
[self rnApplyFilterShadow:nullptr];
|
|
2925
|
+
_extraFilterShadows.clear();
|
|
2926
|
+
[self rnRebuildFilterShadowLayers];
|
|
2927
|
+
return;
|
|
2928
|
+
}
|
|
2929
|
+
|
|
2930
|
+
NSMutableArray<CIFilter *> *built = [NSMutableArray array];
|
|
2931
|
+
if (filters->hasMatrix) {
|
|
2932
|
+
// CIColorMatrix takes the matrix as four input vectors, one per *output*
|
|
2933
|
+
// channel's weights, plus a bias -- which is this matrix row by row.
|
|
2934
|
+
CIFilter *matrix = [CIFilter filterWithName:@"CIColorMatrix"];
|
|
2935
|
+
const auto row = [filters](int index) {
|
|
2936
|
+
return [CIVector vectorWithX:filters->matrix[index * 4 + 0]
|
|
2937
|
+
Y:filters->matrix[index * 4 + 1]
|
|
2938
|
+
Z:filters->matrix[index * 4 + 2]
|
|
2939
|
+
W:filters->matrix[index * 4 + 3]];
|
|
2940
|
+
};
|
|
2941
|
+
[matrix setValue:row(0) forKey:@"inputRVector"];
|
|
2942
|
+
[matrix setValue:row(1) forKey:@"inputGVector"];
|
|
2943
|
+
[matrix setValue:row(2) forKey:@"inputBVector"];
|
|
2944
|
+
[matrix setValue:row(3) forKey:@"inputAVector"];
|
|
2945
|
+
[matrix setValue:[CIVector vectorWithX:filters->offset[0]
|
|
2946
|
+
Y:filters->offset[1]
|
|
2947
|
+
Z:filters->offset[2]
|
|
2948
|
+
W:filters->offset[3]]
|
|
2949
|
+
forKey:@"inputBiasVector"];
|
|
2950
|
+
[built addObject:matrix];
|
|
2951
|
+
}
|
|
2952
|
+
if (filters->blurRadius > 0) {
|
|
2953
|
+
CIFilter *blur = [CIFilter filterWithName:@"CIGaussianBlur"];
|
|
2954
|
+
// Sigma is half the radius, the same conversion the image blur and the box
|
|
2955
|
+
// shadows use.
|
|
2956
|
+
[blur setValue:@(filters->blurRadius / 2) forKey:@"inputRadius"];
|
|
2957
|
+
[built addObject:blur];
|
|
2958
|
+
}
|
|
2959
|
+
_filters = built.count > 0 ? built : nil;
|
|
2960
|
+
self.layer.filters = _filters;
|
|
2961
|
+
|
|
2962
|
+
// `opacity()` is not a Core Image filter here: it multiplies the view's own
|
|
2963
|
+
// opacity, which is a layer property and cheaper than a filter pass. The GTK
|
|
2964
|
+
// side folds it into its opacity node for the same reason.
|
|
2965
|
+
_filterOpacity = filters->opacity;
|
|
2966
|
+
[self rnApplyOpacity];
|
|
2967
|
+
|
|
2968
|
+
// The first is the view's own layer's, where it is cast from the content's
|
|
2969
|
+
// alpha. The rest are layers of their own; see RnAppKitFilterShadowLayer.
|
|
2970
|
+
[self rnApplyFilterShadow:filters->shadowCount > 0 ? filters->shadows : nullptr];
|
|
2971
|
+
_extraFilterShadows.assign(filters->shadows + (filters->shadowCount > 0 ? 1 : 0),
|
|
2972
|
+
filters->shadows + MAX((NSInteger)0, filters->shadowCount));
|
|
2973
|
+
[self rnRebuildFilterShadowLayers];
|
|
2974
|
+
}
|
|
2975
|
+
|
|
2976
|
+
// `dropShadow()`, which is the layer's own shadow rather than a Core Image
|
|
2977
|
+
// filter: with no `shadowPath`, Core Animation casts the shadow from the
|
|
2978
|
+
// layer's *alpha*, which is what the filter function means and what a box shadow
|
|
2979
|
+
// cannot do. The box shadows beside it are separate layers with a path each, so
|
|
2980
|
+
// the two mechanisms do not collide.
|
|
2981
|
+
//
|
|
2982
|
+
// `CALayer.shadowRadius` is the gaussian's standard deviation, so what React
|
|
2983
|
+
// Native parsed crosses unchanged -- where the GTK side doubles it, GSK's radius
|
|
2984
|
+
// being CSS's. React Native's own iOS half passes the same number to SwiftUI's
|
|
2985
|
+
// `.shadow(radius:)`.
|
|
2986
|
+
//
|
|
2987
|
+
// One shadow, because a layer has one. A list of several needs a wrapper layer
|
|
2988
|
+
// each and is recorded in backlog/platform-macos.md rather than half done.
|
|
2989
|
+
- (void)rnApplyFilterShadow:(const RnAppKitFilterShadow *)shadow {
|
|
2990
|
+
if (shadow == nullptr) {
|
|
2991
|
+
if (_filterShadow.color[3] == 0 && _filterShadow.standardDeviation == 0) {
|
|
2992
|
+
return;
|
|
2993
|
+
}
|
|
2994
|
+
_filterShadow = RnAppKitFilterShadow{};
|
|
2995
|
+
self.layer.shadowOpacity = 0;
|
|
2996
|
+
self.layer.shadowColor = nil;
|
|
2997
|
+
self.layer.shadowRadius = 0;
|
|
2998
|
+
self.layer.shadowOffset = CGSizeZero;
|
|
2999
|
+
return;
|
|
3000
|
+
}
|
|
3001
|
+
if (memcmp(&_filterShadow, shadow, sizeof(RnAppKitFilterShadow)) == 0) {
|
|
3002
|
+
return;
|
|
3003
|
+
}
|
|
3004
|
+
_filterShadow = *shadow;
|
|
3005
|
+
|
|
3006
|
+
CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
|
3007
|
+
const CGFloat opaque[4] = {shadow->color[0], shadow->color[1], shadow->color[2], 1};
|
|
3008
|
+
CGColorRef colour = CGColorCreate(space, opaque);
|
|
3009
|
+
self.layer.shadowColor = colour;
|
|
3010
|
+
CGColorRelease(colour);
|
|
3011
|
+
CGColorSpaceRelease(space);
|
|
3012
|
+
// The colour's alpha is the layer's shadowOpacity, which is where Core
|
|
3013
|
+
// Animation keeps it: a shadowColor with an alpha and an opacity of 1 would
|
|
3014
|
+
// multiply the two.
|
|
3015
|
+
self.layer.shadowOpacity = (float)shadow->color[3];
|
|
3016
|
+
self.layer.shadowRadius = shadow->standardDeviation;
|
|
3017
|
+
// This view is flipped, so a positive dy is down, as CSS means it. The layer
|
|
3018
|
+
// is not, which is why the sign is flipped here and not in the shadow path the
|
|
3019
|
+
// box shadows build.
|
|
3020
|
+
self.layer.shadowOffset = CGSizeMake(shadow->dx, -shadow->dy);
|
|
3021
|
+
// No path: the shadow is cast from the alpha, which is the whole point.
|
|
3022
|
+
self.layer.shadowPath = nil;
|
|
3023
|
+
}
|
|
3024
|
+
|
|
3025
|
+
- (RnAppKitFilterShadow)rnFilterShadow {
|
|
3026
|
+
return _filterShadow;
|
|
3027
|
+
}
|
|
3028
|
+
|
|
3029
|
+
- (NSArray<CIFilter *> *)rnFilters {
|
|
3030
|
+
return _filters != nil ? _filters : @[];
|
|
3031
|
+
}
|
|
3032
|
+
|
|
3033
|
+
- (void)setRnHitSlop:(const CGFloat *)insets {
|
|
3034
|
+
BOOL any = NO;
|
|
3035
|
+
for (int edge = 0; edge < 4; edge++) {
|
|
3036
|
+
_hitSlop[edge] = insets != nullptr ? insets[edge] : 0;
|
|
3037
|
+
if (_hitSlop[edge] != 0) {
|
|
3038
|
+
any = YES;
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
3041
|
+
_hasHitSlop = any;
|
|
3042
|
+
// Nothing to redraw: the prop moves no pixels.
|
|
3043
|
+
}
|
|
3044
|
+
|
|
3045
|
+
// The box a press has to land in, which is the bounds unless `hitSlop` says
|
|
3046
|
+
// otherwise. In the view's own coordinates, so a scrolled view's offset is
|
|
3047
|
+
// already in `bounds`.
|
|
3048
|
+
- (NSRect)rnHitArea {
|
|
3049
|
+
const NSRect bounds = self.bounds;
|
|
3050
|
+
if (!_hasHitSlop) {
|
|
3051
|
+
return bounds;
|
|
3052
|
+
}
|
|
3053
|
+
return NSMakeRect(bounds.origin.x - _hitSlop[3],
|
|
3054
|
+
bounds.origin.y - _hitSlop[0],
|
|
3055
|
+
bounds.size.width + _hitSlop[1] + _hitSlop[3],
|
|
3056
|
+
bounds.size.height + _hitSlop[0] + _hitSlop[2]);
|
|
3057
|
+
}
|
|
3058
|
+
|
|
3059
|
+
- (void)setRnBorderStyle:(RnAppKitBorderStyle)style {
|
|
3060
|
+
if (_borderStyle == style) {
|
|
3061
|
+
return;
|
|
3062
|
+
}
|
|
3063
|
+
_borderStyle = style;
|
|
3064
|
+
self.needsDisplay = YES;
|
|
3065
|
+
}
|
|
3066
|
+
|
|
1126
3067
|
- (void)insertRnChild:(RnAppKitView *)child atIndex:(NSInteger)index {
|
|
1127
3068
|
NSArray<NSView *> *children = self.subviews;
|
|
1128
3069
|
if (index >= (NSInteger)children.count) {
|
|
1129
3070
|
[self addSubview:child];
|
|
1130
3071
|
[self rnRaiseIndicators];
|
|
3072
|
+
[self rnPlaceInlineChildren];
|
|
1131
3073
|
return;
|
|
1132
3074
|
}
|
|
1133
3075
|
[self addSubview:child positioned:NSWindowBelow relativeTo:children[(NSUInteger)index]];
|
|
3076
|
+
// A view inline in a paragraph is given its frame before it is inserted --
|
|
3077
|
+
// the mounting walk creates a view, applies its props and only then attaches
|
|
3078
|
+
// it -- so at that moment it has no parent to ask where the text went. This
|
|
3079
|
+
// is where it finds out.
|
|
3080
|
+
[self rnPlaceInlineChildren];
|
|
1134
3081
|
}
|
|
1135
3082
|
|
|
1136
3083
|
// A child added at or past the end lands above the overlay, so put it back on
|
|
@@ -1156,6 +3103,10 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
1156
3103
|
[out appendString:@" "];
|
|
1157
3104
|
}
|
|
1158
3105
|
const NSRect frame = self.frame;
|
|
3106
|
+
// Where the view sits, which for one inline in a paragraph is not the origin
|
|
3107
|
+
// React Native gave it: `textAlignVertical` is the host's own answer to a box
|
|
3108
|
+
// taller than its text, and the other two hosts print the same number. See
|
|
3109
|
+
// rnPlaceInlineChildren.
|
|
1159
3110
|
[out appendFormat:@"view tag=%ld frame=(%g,%g %gx%g)",
|
|
1160
3111
|
(long)self.rnTag,
|
|
1161
3112
|
frame.origin.x,
|
|
@@ -1212,6 +3163,69 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
1212
3163
|
(unsigned)(_borderColors[edge * 4 + 3] * 255.0 + 0.5)];
|
|
1213
3164
|
}
|
|
1214
3165
|
[out appendString:@")"];
|
|
3166
|
+
// The style, when it is not solid. Printed for the same reason the widths
|
|
3167
|
+
// are: a dashed border and a solid one are the same four widths and the same
|
|
3168
|
+
// four colours, and this is the only thing that can say the prop arrived.
|
|
3169
|
+
if (_borderStyle != RnAppKitBorderStyleSolid) {
|
|
3170
|
+
[out appendFormat:@" border-style=%s",
|
|
3171
|
+
_borderStyle == RnAppKitBorderStyleDotted ? "dotted" : "dashed"];
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
// Gradients: how many, each one's line and stop count, where the image goes
|
|
3175
|
+
// and the tile it repeats in, spelled as GTK spells them. Not every stop,
|
|
3176
|
+
// which a transition hint can turn into eleven of: what a cross-host diff
|
|
3177
|
+
// needs is that the same gradient arrived with the same geometry, and the stop
|
|
3178
|
+
// fixup is asserted in core's own tests.
|
|
3179
|
+
//
|
|
3180
|
+
// `at=` is the rectangle the image fills and `tile=` is the period, printed
|
|
3181
|
+
// only when it repeats, so a `no-repeat` background is the line without a
|
|
3182
|
+
// tile. The three background props are invisible in every other line here.
|
|
3183
|
+
for (const RnAppKitGradientRecord &gradient : _gradients) {
|
|
3184
|
+
if (gradient.kind == RnAppKitGradientKindRadial) {
|
|
3185
|
+
[out appendFormat:@" gradient=(radial (%g,%g) %gx%g,%lu stops",
|
|
3186
|
+
(double)gradient.center.x,
|
|
3187
|
+
(double)gradient.center.y,
|
|
3188
|
+
(double)gradient.radiusX,
|
|
3189
|
+
(double)gradient.radiusY,
|
|
3190
|
+
(unsigned long)gradient.stops.size()];
|
|
3191
|
+
} else {
|
|
3192
|
+
[out appendFormat:@" gradient=((%g,%g)-(%g,%g),%lu stops",
|
|
3193
|
+
(double)gradient.start.x,
|
|
3194
|
+
(double)gradient.start.y,
|
|
3195
|
+
(double)gradient.end.x,
|
|
3196
|
+
(double)gradient.end.y,
|
|
3197
|
+
(unsigned long)gradient.stops.size()];
|
|
3198
|
+
}
|
|
3199
|
+
[out appendFormat:@",at=(%g,%g %gx%g)",
|
|
3200
|
+
(double)gradient.area.origin.x,
|
|
3201
|
+
(double)gradient.area.origin.y,
|
|
3202
|
+
(double)gradient.area.size.width,
|
|
3203
|
+
(double)gradient.area.size.height];
|
|
3204
|
+
if (gradient.repeats) {
|
|
3205
|
+
[out appendFormat:@",tile=(%g,%g %gx%g)",
|
|
3206
|
+
(double)gradient.tile.origin.x,
|
|
3207
|
+
(double)gradient.tile.origin.y,
|
|
3208
|
+
(double)gradient.tile.size.width,
|
|
3209
|
+
(double)gradient.tile.size.height];
|
|
3210
|
+
}
|
|
3211
|
+
[out appendString:@")"];
|
|
3212
|
+
}
|
|
3213
|
+
|
|
3214
|
+
// Box shadows, each in full and spelled as GTK spells them. Nothing else in
|
|
3215
|
+
// this dump can say a shadow is there, and the numbers are the whole feature:
|
|
3216
|
+
// an offset that went to the wrong axis or a spread read as a blur still draws
|
|
3217
|
+
// a plausible shadow.
|
|
3218
|
+
for (const RnAppKitBoxShadow &shadow : _boxShadows) {
|
|
3219
|
+
[out appendFormat:@" shadow=(%s%g,%g,%g,%g,#%02x%02x%02x%02x)",
|
|
3220
|
+
shadow.inset ? "inset " : "",
|
|
3221
|
+
(double)shadow.dx,
|
|
3222
|
+
(double)shadow.dy,
|
|
3223
|
+
(double)shadow.blur,
|
|
3224
|
+
(double)shadow.spread,
|
|
3225
|
+
(unsigned)(shadow.color[0] * 255.0 + 0.5),
|
|
3226
|
+
(unsigned)(shadow.color[1] * 255.0 + 0.5),
|
|
3227
|
+
(unsigned)(shadow.color[2] * 255.0 + 0.5),
|
|
3228
|
+
(unsigned)(shadow.color[3] * 255.0 + 0.5)];
|
|
1215
3229
|
}
|
|
1216
3230
|
if (_hasTransform) {
|
|
1217
3231
|
// The 2D affine part, which is all either platform draws, in the order
|
|
@@ -1232,6 +3246,12 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
1232
3246
|
if (scroll.x != 0 || scroll.y != 0) {
|
|
1233
3247
|
[out appendFormat:@" scroll=(%g,%g)", scroll.x, scroll.y];
|
|
1234
3248
|
}
|
|
3249
|
+
// `pagingEnabled`, `snapToInterval`, `snapToOffsets` and the rest, which
|
|
3250
|
+
// decide where a fling ends and so are invisible to a dump taken while
|
|
3251
|
+
// nothing moves. Formatted in core, so all three hosts print the same words.
|
|
3252
|
+
if (self.rnScrollSnap.length > 0) {
|
|
3253
|
+
[out appendString:self.rnScrollSnap];
|
|
3254
|
+
}
|
|
1235
3255
|
// The overlay scrollbars, which are otherwise pure paint and so invisible to
|
|
1236
3256
|
// every test this project has. Printed only when there is one, so a view that
|
|
1237
3257
|
// does not scroll stays as short as it was.
|
|
@@ -1245,6 +3265,18 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
1245
3265
|
_indicators.rnHorizontalOffset,
|
|
1246
3266
|
_indicators.rnHorizontalLength];
|
|
1247
3267
|
}
|
|
3268
|
+
// The thumb's colour, printed only when it is not the default black: a white
|
|
3269
|
+
// one is `indicatorStyle` having arrived, and the colour is the only part of
|
|
3270
|
+
// that a tree dump can see.
|
|
3271
|
+
if (_rnIndicatorColour != nil
|
|
3272
|
+
&& (_rnIndicatorColour.redComponent != 0.0 || _rnIndicatorColour.greenComponent != 0.0
|
|
3273
|
+
|| _rnIndicatorColour.blueComponent != 0.0)) {
|
|
3274
|
+
[out appendFormat:@" scrollbar-colour=(%g,%g,%g,%g)",
|
|
3275
|
+
_rnIndicatorColour.redComponent,
|
|
3276
|
+
_rnIndicatorColour.greenComponent,
|
|
3277
|
+
_rnIndicatorColour.blueComponent,
|
|
3278
|
+
_rnIndicatorColour.alphaComponent];
|
|
3279
|
+
}
|
|
1248
3280
|
// Printed only when it is not the default, like every other field here.
|
|
1249
3281
|
// Worth printing at all because it is invisible: a view with
|
|
1250
3282
|
// `pointerEvents: none` is drawn exactly like one without, and the only way
|
|
@@ -1253,6 +3285,117 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
1253
3285
|
if (self.rnPointerEvents != RnAppKitPointerEventsAuto) {
|
|
1254
3286
|
[out appendFormat:@" pe=%s", RnAppKitPointerEventsName(self.rnPointerEvents)];
|
|
1255
3287
|
}
|
|
3288
|
+
// Printed for the reason pointerEvents is, and spelled as GTK spells it: the
|
|
3289
|
+
// prop is invisible in a still picture, so a dump is the only thing that can
|
|
3290
|
+
// say it arrived on both hosts.
|
|
3291
|
+
if (_cursorName != nil) {
|
|
3292
|
+
[out appendFormat:@" cursor=%@", _cursorName];
|
|
3293
|
+
}
|
|
3294
|
+
// The blend mode the app asked for, spelled as GTK spells it: the keyword,
|
|
3295
|
+
// not the Core Image filter it became.
|
|
3296
|
+
if (_blendModeName != nil) {
|
|
3297
|
+
[out appendFormat:@" blend=%@", _blendModeName];
|
|
3298
|
+
}
|
|
3299
|
+
// The outline, which is invisible in every other line: it is not a border and
|
|
3300
|
+
// a view with one has the same frame and the same colours without it. Spelled
|
|
3301
|
+
// as GTK spells it.
|
|
3302
|
+
if (_outlineWidth > 0) {
|
|
3303
|
+
[out appendFormat:@" outline=(%g,%g,#%02x%02x%02x%02x",
|
|
3304
|
+
(double)_outlineWidth,
|
|
3305
|
+
(double)_outlineOffset,
|
|
3306
|
+
(unsigned)(_outlineColor[0] * 255.0 + 0.5),
|
|
3307
|
+
(unsigned)(_outlineColor[1] * 255.0 + 0.5),
|
|
3308
|
+
(unsigned)(_outlineColor[2] * 255.0 + 0.5),
|
|
3309
|
+
(unsigned)(_outlineColor[3] * 255.0 + 0.5)];
|
|
3310
|
+
if (_outlineStyle != RnAppKitBorderStyleSolid) {
|
|
3311
|
+
[out appendFormat:@",%s",
|
|
3312
|
+
_outlineStyle == RnAppKitBorderStyleDotted ? "dotted" : "dashed"];
|
|
3313
|
+
}
|
|
3314
|
+
[out appendString:@")"];
|
|
3315
|
+
}
|
|
3316
|
+
|
|
3317
|
+
// `filter`, as the pieces it came to plus what its matrix makes of one probe
|
|
3318
|
+
// colour, spelled exactly as GTK spells it: sixteen numbers would drown the
|
|
3319
|
+
// line, and one colour still fails when a matrix is wrong. The probe is
|
|
3320
|
+
// (1, 0.5, 0.25) so no two channels can be swapped unnoticed.
|
|
3321
|
+
if (_filters != nil || _filterOpacity < 1 || _filterShadow.color[3] > 0) {
|
|
3322
|
+
[out appendString:@" filter=("];
|
|
3323
|
+
BOOL first = YES;
|
|
3324
|
+
for (CIFilter *filter in _filters) {
|
|
3325
|
+
if ([filter.name isEqualToString:@"CIColorMatrix"]) {
|
|
3326
|
+
const CGFloat probe[4] = {1.0, 0.5, 0.25, 1.0};
|
|
3327
|
+
CGFloat result[4] = {0, 0, 0, 0};
|
|
3328
|
+
NSArray<NSString *> *keys =
|
|
3329
|
+
@[ @"inputRVector", @"inputGVector", @"inputBVector", @"inputAVector" ];
|
|
3330
|
+
CIVector *bias = [filter valueForKey:@"inputBiasVector"];
|
|
3331
|
+
for (int row = 0; row < 4; row++) {
|
|
3332
|
+
CIVector *weights = [filter valueForKey:keys[(NSUInteger)row]];
|
|
3333
|
+
result[row] = [bias valueAtIndex:(size_t)row] + weights.X * probe[0] +
|
|
3334
|
+
weights.Y * probe[1] + weights.Z * probe[2] + weights.W * probe[3];
|
|
3335
|
+
}
|
|
3336
|
+
const auto byte = [](CGFloat value) {
|
|
3337
|
+
const CGFloat clamped = value < 0 ? 0 : (value > 1 ? 1 : value);
|
|
3338
|
+
return (unsigned)(clamped * 255.0 + 0.5);
|
|
3339
|
+
};
|
|
3340
|
+
[out appendFormat:@"probe=#%02x%02x%02x%02x",
|
|
3341
|
+
byte(result[0]),
|
|
3342
|
+
byte(result[1]),
|
|
3343
|
+
byte(result[2]),
|
|
3344
|
+
byte(result[3])];
|
|
3345
|
+
first = NO;
|
|
3346
|
+
} else if ([filter.name isEqualToString:@"CIGaussianBlur"]) {
|
|
3347
|
+
const double sigma = [[filter valueForKey:@"inputRadius"] doubleValue];
|
|
3348
|
+
[out appendFormat:@"%sblur=%g", first ? "" : ",", sigma * 2.0];
|
|
3349
|
+
first = NO;
|
|
3350
|
+
}
|
|
3351
|
+
}
|
|
3352
|
+
if (_filterOpacity < 1) {
|
|
3353
|
+
[out appendFormat:@"%sopacity=%g", first ? "" : ",", (double)_filterOpacity];
|
|
3354
|
+
first = NO;
|
|
3355
|
+
}
|
|
3356
|
+
// The drop shadow, with the standard deviation React Native parsed, which is
|
|
3357
|
+
// also what this layer was given. GTK prints the same number from a radius
|
|
3358
|
+
// it doubled.
|
|
3359
|
+
if (_filterShadow.color[3] > 0) {
|
|
3360
|
+
[out appendFormat:@"%sshadow=(%g,%g,%g,#%02x%02x%02x%02x)",
|
|
3361
|
+
first ? "" : ",",
|
|
3362
|
+
(double)_filterShadow.dx,
|
|
3363
|
+
(double)_filterShadow.dy,
|
|
3364
|
+
(double)_filterShadow.standardDeviation,
|
|
3365
|
+
(unsigned)(_filterShadow.color[0] * 255.0 + 0.5),
|
|
3366
|
+
(unsigned)(_filterShadow.color[1] * 255.0 + 0.5),
|
|
3367
|
+
(unsigned)(_filterShadow.color[2] * 255.0 + 0.5),
|
|
3368
|
+
(unsigned)(_filterShadow.color[3] * 255.0 + 0.5)];
|
|
3369
|
+
}
|
|
3370
|
+
[out appendString:@")"];
|
|
3371
|
+
}
|
|
3372
|
+
|
|
3373
|
+
// `hitSlop`, which is invisible in every other line of this dump: a view with
|
|
3374
|
+
// a bigger target is drawn exactly like one without.
|
|
3375
|
+
if (_hasHitSlop) {
|
|
3376
|
+
[out appendFormat:@" hit-slop=(%g,%g,%g,%g)",
|
|
3377
|
+
(double)_hitSlop[0],
|
|
3378
|
+
(double)_hitSlop[1],
|
|
3379
|
+
(double)_hitSlop[2],
|
|
3380
|
+
(double)_hitSlop[3]];
|
|
3381
|
+
}
|
|
3382
|
+
// The resolved labelled-by relation, by tag, spelled as GTK spells it. Every
|
|
3383
|
+
// tag that resolved, not only the one AppKit could use, so the two hosts print
|
|
3384
|
+
// the same line and the difference stays in what each platform does with it.
|
|
3385
|
+
if (!_labelledBy.empty()) {
|
|
3386
|
+
[out appendString:@" labelled-by="];
|
|
3387
|
+
for (size_t i = 0; i < _labelledBy.size(); i++) {
|
|
3388
|
+
[out appendFormat:@"%s%ld", i == 0 ? "" : ",", (long)_labelledBy[i]];
|
|
3389
|
+
}
|
|
3390
|
+
}
|
|
3391
|
+
// The resolved reading order, by tag and in the order the app asked for,
|
|
3392
|
+
// spelled as GTK spells it so the two dumps compare.
|
|
3393
|
+
if (!_accessibilityOrder.empty()) {
|
|
3394
|
+
[out appendString:@" a11y-order="];
|
|
3395
|
+
for (size_t i = 0; i < _accessibilityOrder.size(); i++) {
|
|
3396
|
+
[out appendFormat:@"%s%ld", i == 0 ? "" : ",", (long)_accessibilityOrder[i]];
|
|
3397
|
+
}
|
|
3398
|
+
}
|
|
1256
3399
|
if (_image != nullptr) {
|
|
1257
3400
|
// The same `texture=WxH fit=<name>` the GTK side emits, so an <Image> shows
|
|
1258
3401
|
// up in the cross-platform diff and the end-to-end suite can assert on it.
|
|
@@ -1261,6 +3404,14 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
1261
3404
|
// identical in every other line of this dump and different on screen.
|
|
1262
3405
|
[out appendFormat:@" texture=%zux%zu", CGImageGetWidth(_image), CGImageGetHeight(_image)];
|
|
1263
3406
|
[out appendFormat:@" fit=%s", RnAppKitImageFitName(_imageFit)];
|
|
3407
|
+
// That this image moves, which no other line can show: an animated GIF and
|
|
3408
|
+
// its first frame are the same size and the same picture in a snapshot.
|
|
3409
|
+
// Not which frame, deliberately -- the two hosts tick on their own clocks
|
|
3410
|
+
// and a cross-host diff would then be a race. Each suite asserts the frames
|
|
3411
|
+
// itself.
|
|
3412
|
+
if (_imageFrames.count > 1) {
|
|
3413
|
+
[out appendString:@" animated=1"];
|
|
3414
|
+
}
|
|
1264
3415
|
// Printed for the same reason the fit is: a tinted image and an untinted
|
|
1265
3416
|
// one are identical in every other line of this dump and different on
|
|
1266
3417
|
// screen. Formatted exactly as GTK formats a colour, so the cross-host diff
|
|
@@ -1273,6 +3424,13 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
1273
3424
|
(unsigned)(rgb.blueComponent * 255.0 + 0.5),
|
|
1274
3425
|
(unsigned)(rgb.alphaComponent * 255.0 + 0.5)];
|
|
1275
3426
|
}
|
|
3427
|
+
// And the blur, spelled as GTK spells it. Invisible in this dump for the
|
|
3428
|
+
// reason the tint is, and in one more: the prop is read in a branch that
|
|
3429
|
+
// knows ImageProps, and a line here is the only thing that can say it got
|
|
3430
|
+
// out of that branch and onto the view.
|
|
3431
|
+
if (_imageBlur > 0) {
|
|
3432
|
+
[out appendFormat:@" blur=%g", (double)_imageBlur];
|
|
3433
|
+
}
|
|
1276
3434
|
}
|
|
1277
3435
|
if (_textLayout != nil) {
|
|
1278
3436
|
NSString *text = _textLayout.attributedString.string;
|
|
@@ -1289,6 +3447,80 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
1289
3447
|
[out appendFormat:@" text=\"%@\"", escaped];
|
|
1290
3448
|
}
|
|
1291
3449
|
}
|
|
3450
|
+
// The paragraph's writing direction, when an app asked for one. Nothing else
|
|
3451
|
+
// here shows it: a right-to-left paragraph of Latin text has the same box and
|
|
3452
|
+
// the same string, and only the pixels differ. `natural` is printed too,
|
|
3453
|
+
// asking for the algorithm's answer not being the same as saying nothing.
|
|
3454
|
+
if (self.rnTextAlign.length > 0) {
|
|
3455
|
+
[out appendFormat:@" text-align=%@", self.rnTextAlign];
|
|
3456
|
+
}
|
|
3457
|
+
if (self.rnTextVerticalAlign.length > 0) {
|
|
3458
|
+
[out appendFormat:@" text-valign=%@", self.rnTextVerticalAlign];
|
|
3459
|
+
}
|
|
3460
|
+
// A <Text>'s own padding and border. Invisible in every other line here --
|
|
3461
|
+
// the frame is the box including the padding, and the string is the same
|
|
3462
|
+
// string -- and it decides where the text is drawn and how wide it wraps.
|
|
3463
|
+
if (_rnTextInset.left != 0 || _rnTextInset.top != 0 || _rnTextInset.right != 0 ||
|
|
3464
|
+
_rnTextInset.bottom != 0) {
|
|
3465
|
+
[out appendFormat:@" text-inset=%g,%g,%g,%g", _rnTextInset.left, _rnTextInset.top,
|
|
3466
|
+
_rnTextInset.right, _rnTextInset.bottom];
|
|
3467
|
+
}
|
|
3468
|
+
// `<Text selectable>`, and what is selected in it. Neither can be seen any
|
|
3469
|
+
// other way in a dump: the prop changes no box and the highlight is a wash
|
|
3470
|
+
// under the glyphs. The range is in this host's own unit -- UTF-16 code
|
|
3471
|
+
// units, where GTK counts bytes -- so an end-to-end scenario compares
|
|
3472
|
+
// "something is selected" rather than the numbers.
|
|
3473
|
+
if (self.rnTextSelectable) {
|
|
3474
|
+
[out appendString:@" selectable"];
|
|
3475
|
+
}
|
|
3476
|
+
if ([self rnTextSelectionLength] > 0) {
|
|
3477
|
+
[out appendFormat:@" selection=%ld,%ld", (long)[self rnTextSelectionStart],
|
|
3478
|
+
(long)[self rnTextSelectionLength]];
|
|
3479
|
+
}
|
|
3480
|
+
if (self.rnWritingDirection.length > 0) {
|
|
3481
|
+
[out appendFormat:@" writing-dir=%@", self.rnWritingDirection];
|
|
3482
|
+
}
|
|
3483
|
+
// What a field asked for about spelling, in the same words GTK prints.
|
|
3484
|
+
// Neither appears for a field that said nothing.
|
|
3485
|
+
if (self.rnSpellCheck.length > 0) {
|
|
3486
|
+
[out appendFormat:@" spellcheck=%@", self.rnSpellCheck];
|
|
3487
|
+
}
|
|
3488
|
+
if (self.rnAutoCorrect.length > 0) {
|
|
3489
|
+
[out appendFormat:@" autocorrect=%@", self.rnAutoCorrect];
|
|
3490
|
+
}
|
|
3491
|
+
// What the app asked for about capitalisation and the keyboard, neither of
|
|
3492
|
+
// which this platform can act on. The words are GTK's so the dumps compare.
|
|
3493
|
+
if (self.rnAutoCapitalize.length > 0) {
|
|
3494
|
+
[out appendFormat:@" autocapitalize=%@", self.rnAutoCapitalize];
|
|
3495
|
+
}
|
|
3496
|
+
if (self.rnKeyboardType.length > 0) {
|
|
3497
|
+
[out appendFormat:@" keyboard=%@", self.rnKeyboardType];
|
|
3498
|
+
}
|
|
3499
|
+
// `caretHidden` and `contextMenuHidden`, printed only when asked for. Each is
|
|
3500
|
+
// the absence of something -- a blink, a menu -- so there is nothing else for
|
|
3501
|
+
// a test or the cross-host diff to look at.
|
|
3502
|
+
if (self.rnCaretHidden) {
|
|
3503
|
+
[out appendString:@" caret=hidden"];
|
|
3504
|
+
}
|
|
3505
|
+
if (self.rnContextMenuHidden) {
|
|
3506
|
+
[out appendString:@" context-menu=hidden"];
|
|
3507
|
+
}
|
|
3508
|
+
// The paragraph's text shadow, spelled as GTK spells it: no other line can
|
|
3509
|
+
// show it, a shadowed paragraph having the same text, colour and box. The
|
|
3510
|
+
// standard deviation React Native parsed, which is also what this context was
|
|
3511
|
+
// given.
|
|
3512
|
+
if (_textLayout != nil && _textLayout.shadowColor != nil) {
|
|
3513
|
+
NSColor *colour = [_textLayout.shadowColor
|
|
3514
|
+
colorUsingColorSpace:[NSColorSpace sRGBColorSpace]];
|
|
3515
|
+
[out appendFormat:@" text-shadow=(%g,%g,%g,#%02x%02x%02x%02x)",
|
|
3516
|
+
(double)_textLayout.shadowOffset.width,
|
|
3517
|
+
(double)_textLayout.shadowOffset.height,
|
|
3518
|
+
(double)_textLayout.shadowStandardDeviation,
|
|
3519
|
+
(unsigned)(colour.redComponent * 255.0 + 0.5),
|
|
3520
|
+
(unsigned)(colour.greenComponent * 255.0 + 0.5),
|
|
3521
|
+
(unsigned)(colour.blueComponent * 255.0 + 0.5),
|
|
3522
|
+
(unsigned)(colour.alphaComponent * 255.0 + 0.5)];
|
|
3523
|
+
}
|
|
1292
3524
|
|
|
1293
3525
|
// A text field's content lives in its NSTextField peer, not in anything this
|
|
1294
3526
|
// view draws, so it would otherwise be invisible to every test that reads
|
|
@@ -1339,6 +3571,12 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
1339
3571
|
if (_roleName != nil) {
|
|
1340
3572
|
[out appendFormat:@" role=%@", _roleName];
|
|
1341
3573
|
}
|
|
3574
|
+
// `testID`, as React Native spelled it rather than as AppKit publishes it,
|
|
3575
|
+
// which is the same division as the role: that this platform really set
|
|
3576
|
+
// `accessibilityIdentifier` is asserted in tests/test_appkit_accessibility.mm.
|
|
3577
|
+
if (self.rnTestId.length > 0) {
|
|
3578
|
+
[out appendFormat:@" testid=%@", self.rnTestId];
|
|
3579
|
+
}
|
|
1342
3580
|
// Whether Tab stops here, after `role=` because that is where the GTK side
|
|
1343
3581
|
// prints it and this dump is diffed line by line. Whether it is focused *now*
|
|
1344
3582
|
// is deliberately not printed: that depends on what the window server did
|
|
@@ -1347,6 +3585,11 @@ static const char *RnAppKitImageFitName(RnAppKitImageFit fit) {
|
|
|
1347
3585
|
if (self.rnFocusable) {
|
|
1348
3586
|
[out appendString:@" focusable"];
|
|
1349
3587
|
}
|
|
3588
|
+
// `accessibilityViewIsModal`, after `focusable` because that is the order the
|
|
3589
|
+
// GTK side prints and this dump is diffed line by line.
|
|
3590
|
+
if (self.rnAccessibleModal) {
|
|
3591
|
+
[out appendString:@" modal"];
|
|
3592
|
+
}
|
|
1350
3593
|
[out appendString:@"\n"];
|
|
1351
3594
|
|
|
1352
3595
|
for (NSView *child in self.subviews) {
|