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.
Files changed (42) hide show
  1. package/native/CMakeLists.txt +16 -2
  2. package/native/appkit/AppKitAppWindow.h +24 -0
  3. package/native/appkit/AppKitImageLoader.h +48 -1
  4. package/native/appkit/AppKitImageLoader.mm +228 -10
  5. package/native/appkit/AppKitMenuBar.mm +58 -0
  6. package/native/appkit/AppKitMountingManager.h +34 -0
  7. package/native/appkit/AppKitMountingManager.mm +576 -9
  8. package/native/appkit/AppKitScrollView.h +48 -0
  9. package/native/appkit/AppKitScrollView.mm +210 -0
  10. package/native/appkit/AppKitTextInput.h +38 -1
  11. package/native/appkit/AppKitTextInput.mm +235 -8
  12. package/native/appkit/AppKitTextPeer.h +60 -0
  13. package/native/appkit/AppKitTextPeer.mm +307 -0
  14. package/native/appkit/AppKitTouchDispatcher.h +56 -0
  15. package/native/appkit/AppKitTouchDispatcher.mm +140 -0
  16. package/native/appkit/AppKitWindowControl.mm +38 -3
  17. package/native/appkit/ComponentRegistryAppKit.mm +20 -0
  18. package/native/appkit/CoreTextLayout.h +64 -2
  19. package/native/appkit/CoreTextLayout.mm +431 -71
  20. package/native/appkit/CoreTextLayoutManager.mm +57 -6
  21. package/native/appkit/FontRegistryCoreText.mm +6 -5
  22. package/native/appkit/PlatformServicesAppKit.mm +101 -20
  23. package/native/appkit/RnAppKitView.h +492 -0
  24. package/native/appkit/RnAppKitView.mm +2266 -23
  25. package/native/appkit/RnTextLayout.h +106 -0
  26. package/native/appkit/RnTextLayout.mm +321 -2
  27. package/native/appkit/main_appkit.mm +174 -3
  28. package/native/tests/test_appkit_accessibility.mm +289 -0
  29. package/native/tests/test_appkit_borders.mm +975 -0
  30. package/native/tests/test_appkit_fonts.mm +203 -0
  31. package/native/tests/test_appkit_image.mm +392 -17
  32. package/native/tests/test_appkit_input.mm +209 -0
  33. package/native/tests/test_appkit_mounting.mm +360 -0
  34. package/native/tests/test_appkit_scrollbar.mm +125 -0
  35. package/native/tests/test_appkit_scrollview.mm +371 -0
  36. package/native/tests/test_appkit_selection.mm +349 -0
  37. package/native/tests/test_appkit_services.mm +51 -1
  38. package/native/tests/test_appkit_text.mm +1628 -0
  39. package/native/tests/test_appkit_textinput.mm +819 -0
  40. package/native/tests/test_appkit_titlebar.mm +43 -0
  41. package/native/tests/test_appkit_view.mm +609 -0
  42. package/package.json +4 -3
@@ -13,6 +13,8 @@
13
13
 
14
14
  #import "AppKitTitleBar.h"
15
15
 
16
+ #include "WindowControl.h"
17
+
16
18
  TEST(titlebar_native_style_costs_the_app_nothing) {
17
19
  @autoreleasepool {
18
20
  NSWindow *window = [[NSWindow alloc]
@@ -105,3 +107,44 @@ TEST(titlebar_actions_are_safe_with_no_window) {
105
107
  EXPECT(true);
106
108
  }
107
109
  }
110
+
111
+ // Which window `core/WindowControl.h` answers about, when the app is not the
112
+ // active one.
113
+ //
114
+ // `NSApp.keyWindow` and `NSApp.mainWindow` are both nil for a background app,
115
+ // and this file's suite *is* a background app: nothing here activates. So a
116
+ // bounds read that only asked those two reported zero, which is what
117
+ // `e2e/window.tsx` was seeing -- `getBounds()` answering 0x0 for the whole life
118
+ // of a host that was launched while something else had focus.
119
+ TEST(appkit_window_bounds_do_not_need_the_app_to_be_active) {
120
+ @autoreleasepool {
121
+ NSWindow *window = [[NSWindow alloc]
122
+ initWithContentRect:NSMakeRect(0, 0, 640, 480)
123
+ styleMask:NSWindowStyleMaskTitled | NSWindowStyleMaskResizable
124
+ backing:NSBackingStoreBuffered
125
+ defer:NO];
126
+ // Ordered front rather than made key: the point is a window that exists and
127
+ // is visible without the app being frontmost.
128
+ // Not released when closed, which is the same line main_appkit.mm needs for
129
+ // the windows the host makes and for the same reason: `close` on a window
130
+ // whose `releasedWhenClosed` is YES -- the default for one built in code --
131
+ // releases it while ARC still holds a reference, and the second release
132
+ // comes when the pool drains.
133
+ //
134
+ // Undefined behaviour, which behaved: it passed here and segfaulted on
135
+ // CI's Mac. See test_appkit_services.mm for the rest of that story.
136
+ window.releasedWhenClosed = NO;
137
+ [window orderFront:nil];
138
+
139
+ const basalt::WindowBounds bounds = basalt::windowBounds();
140
+ // The frame rather than the content rect, which is what `getBounds()`
141
+ // reports and is taller by the title bar.
142
+ EXPECT(bounds.width >= 640.0);
143
+ EXPECT(bounds.height >= 480.0);
144
+ EXPECT_EQ(bounds.width, window.frame.size.width);
145
+ EXPECT_EQ(bounds.height, window.frame.size.height);
146
+
147
+ [window orderOut:nil];
148
+ [window close];
149
+ }
150
+ }
@@ -7,6 +7,8 @@
7
7
 
8
8
  #include "TestHarness.h"
9
9
 
10
+ #include "Filters.h"
11
+
10
12
  #import "RnAppKitView.h"
11
13
 
12
14
  #include <sstream>
@@ -235,3 +237,610 @@ TEST(appkit_view_describes_its_tree_like_the_gtk_one) {
235
237
  int main(int argc, char **argv) {
236
238
  return basalt::testing::runAllTests(argc, argv);
237
239
  }
240
+
241
+ // The `cursor` style property.
242
+ //
243
+ // Two halves, and they fail differently: which NSCursor a CSS keyword means, and
244
+ // whether the view tells AppKit about it. The second is a cursor *rect*, which
245
+ // AppKit asks for by calling `resetCursorRects`, so the test subclass below
246
+ // records what the view adds rather than needing a window and a real pointer.
247
+ // An Objective-C class cannot be declared inside a namespace, which is why this
248
+ // one is not in the anonymous namespace the rest of the file's helpers are in.
249
+ @interface RnRecordingCursorView : RnAppKitView
250
+ @property(nonatomic, strong, nullable) NSCursor *recorded;
251
+ @property(nonatomic) NSInteger rectCount;
252
+ @end
253
+
254
+ @implementation RnRecordingCursorView
255
+ - (void)addCursorRect:(NSRect)rect cursor:(NSCursor *)cursor {
256
+ // Deliberately not calling super: without a window there is nothing to add it
257
+ // to, and what is being checked is what the view asked for.
258
+ (void)rect;
259
+ self.recorded = cursor;
260
+ self.rectCount++;
261
+ }
262
+ @end
263
+
264
+ namespace {
265
+
266
+ RnRecordingCursorView *cursorView(NSString *name) {
267
+ RnRecordingCursorView *view = [[RnRecordingCursorView alloc] initWithFrame:NSZeroRect];
268
+ [view setRnFrameX:0 y:0 width:100 height:50];
269
+ [view setRnCursorName:name];
270
+ [view resetCursorRects];
271
+ return view;
272
+ }
273
+
274
+ } // namespace
275
+
276
+ TEST(cursor_pointer_is_the_pointing_hand) {
277
+ @autoreleasepool {
278
+ RnRecordingCursorView *view = cursorView(@"pointer");
279
+ EXPECT(view.rnResolvedCursor == NSCursor.pointingHandCursor);
280
+ // And it reached AppKit, which is the half a stored property cannot show.
281
+ EXPECT_EQ((long)view.rectCount, 1L);
282
+ EXPECT(view.recorded == NSCursor.pointingHandCursor);
283
+ }
284
+ }
285
+
286
+ TEST(cursor_keywords_map_to_the_cursors_macos_has) {
287
+ @autoreleasepool {
288
+ EXPECT(cursorView(@"text").rnResolvedCursor == NSCursor.IBeamCursor);
289
+ EXPECT(cursorView(@"default").rnResolvedCursor == NSCursor.arrowCursor);
290
+ EXPECT(cursorView(@"grab").rnResolvedCursor == NSCursor.openHandCursor);
291
+ EXPECT(cursorView(@"grabbing").rnResolvedCursor == NSCursor.closedHandCursor);
292
+ EXPECT(cursorView(@"crosshair").rnResolvedCursor == NSCursor.crosshairCursor);
293
+ EXPECT(cursorView(@"copy").rnResolvedCursor == NSCursor.dragCopyCursor);
294
+ EXPECT(cursorView(@"alias").rnResolvedCursor == NSCursor.dragLinkCursor);
295
+ EXPECT(cursorView(@"context-menu").rnResolvedCursor == NSCursor.contextualMenuCursor);
296
+ // CSS has two keywords for this and macOS has one cursor.
297
+ EXPECT(cursorView(@"not-allowed").rnResolvedCursor == NSCursor.operationNotAllowedCursor);
298
+ EXPECT(cursorView(@"no-drop").rnResolvedCursor == NSCursor.operationNotAllowedCursor);
299
+ // The axis-aligned resizes collapse onto the two double-headed arrows, since
300
+ // "e-resize" and "w-resize" are the same picture on this platform.
301
+ EXPECT(cursorView(@"ew-resize").rnResolvedCursor == NSCursor.resizeLeftRightCursor);
302
+ EXPECT(cursorView(@"e-resize").rnResolvedCursor == NSCursor.resizeLeftRightCursor);
303
+ EXPECT(cursorView(@"ns-resize").rnResolvedCursor == NSCursor.resizeUpDownCursor);
304
+ EXPECT(cursorView(@"s-resize").rnResolvedCursor == NSCursor.resizeUpDownCursor);
305
+ // "none" hides the pointer, which is a cursor made of an empty image rather
306
+ // than the absence of one: nil would mean "inherit" and show an arrow.
307
+ EXPECT(cursorView(@"none").rnResolvedCursor != nil);
308
+ }
309
+ }
310
+
311
+ // A keyword macOS has no cursor for installs no rect, so the cursor is whatever
312
+ // encloses the view. Forcing an arrow instead would be worse than doing nothing:
313
+ // a spinner view inside a window showing a busy cursor would cut a hole in it.
314
+ TEST(cursor_a_keyword_macos_lacks_leaves_the_cursor_alone) {
315
+ @autoreleasepool {
316
+ for (NSString *name in @[@"wait", @"help", @"move", @"progress", @"cell", @"all-scroll"]) {
317
+ RnRecordingCursorView *view = cursorView(name);
318
+ EXPECT(view.rnResolvedCursor == nil);
319
+ EXPECT_EQ((long)view.rectCount, 0L);
320
+ }
321
+ }
322
+ }
323
+
324
+ TEST(cursor_auto_is_no_cursor_at_all) {
325
+ @autoreleasepool {
326
+ // What `cursor: 'auto'` becomes by the time it is here: nothing to apply.
327
+ RnRecordingCursorView *view = cursorView(nil);
328
+ EXPECT(view.rnResolvedCursor == nil);
329
+ EXPECT_EQ((long)view.rectCount, 0L);
330
+ EXPECT(![[view describeTree] containsString:@"cursor="]);
331
+ }
332
+ }
333
+
334
+ // Clearing has to clear. A view whose cursor prop goes away keeps showing the
335
+ // hand otherwise, because a cursor rect lives in the window until it is
336
+ // discarded.
337
+ TEST(cursor_can_be_taken_away_again) {
338
+ @autoreleasepool {
339
+ RnRecordingCursorView *view = cursorView(@"pointer");
340
+ [view setRnCursorName:nil];
341
+ view.rectCount = 0;
342
+ [view resetCursorRects];
343
+ EXPECT(view.rnResolvedCursor == nil);
344
+ EXPECT_EQ((long)view.rectCount, 0L);
345
+ }
346
+ }
347
+
348
+ // And the keyword is in the tree dump, which is what makes the wiring testable:
349
+ // the prop is read in AppKitMountingManager, and every test above sets the name
350
+ // on the view by hand. Spelled as the GTK side spells it, so the cross-host diff
351
+ // can compare them.
352
+ TEST(cursor_is_reported_in_the_tree) {
353
+ @autoreleasepool {
354
+ RnAppKitView *view = [RnAppKitView viewWithTag:1];
355
+ [view setRnFrameX:0 y:0 width:100 height:50];
356
+ [view setRnCursorName:@"grab"];
357
+ EXPECT([[view describeTree] containsString:@"cursor=grab"]);
358
+ // A keyword macOS cannot show is still reported: the dump says what the app
359
+ // asked for, and the two hosts' trees have to match whatever each can draw.
360
+ [view setRnCursorName:@"wait"];
361
+ EXPECT([[view describeTree] containsString:@"cursor=wait"]);
362
+ }
363
+ }
364
+
365
+ // `filter`: the CSS filter functions, as Core Image filters on the layer.
366
+ //
367
+ // `CALayer.filters` is public on macOS and private on iOS, which is why React
368
+ // Native's own iOS half builds a SwiftUI wrapper and a multiply-blend layer to
369
+ // approximate what this sets directly. What is asserted is what each filter was
370
+ // handed: a filter, like a shadow, is composited by Core Animation, and
371
+ // `renderInContext:` draws neither -- measured earlier on a bare layer. The GTK
372
+ // side has the render tree and checks a rendered pixel instead; the arithmetic
373
+ // they share is tested in core.
374
+ namespace {
375
+
376
+ RnAppKitFilters resolvedFilters(const std::vector<facebook::react::FilterFunction> &functions) {
377
+ const basalt::ResolvedFilters resolved = basalt::resolveFilters(functions);
378
+ RnAppKitFilters filters{};
379
+ filters.hasMatrix = resolved.hasMatrix;
380
+ for (int i = 0; i < 16; i++) {
381
+ filters.matrix[i] = resolved.matrix.m[i];
382
+ }
383
+ for (int i = 0; i < 4; i++) {
384
+ filters.offset[i] = resolved.matrix.offset[i];
385
+ }
386
+ filters.blurRadius = resolved.blurRadius;
387
+ filters.opacity = resolved.opacity;
388
+ return filters;
389
+ }
390
+
391
+ facebook::react::FilterFunction filterFunction(facebook::react::FilterType type, double amount) {
392
+ facebook::react::FilterFunction function;
393
+ function.type = type;
394
+ function.parameters = static_cast<facebook::react::Float>(amount);
395
+ return function;
396
+ }
397
+
398
+ } // namespace
399
+
400
+ TEST(filter_a_colour_matrix_reaches_core_image_row_by_row) {
401
+ @autoreleasepool {
402
+ RnAppKitView *view = [RnAppKitView viewWithTag:1];
403
+ EXPECT_EQ((long)view.rnFilters.count, 0L);
404
+
405
+ using facebook::react::FilterType;
406
+ const RnAppKitFilters filters = resolvedFilters({filterFunction(FilterType::Grayscale, 1.0)});
407
+ [view setRnFilters:&filters];
408
+
409
+ EXPECT_EQ((long)view.rnFilters.count, 1L);
410
+ CIFilter *matrix = view.rnFilters.firstObject;
411
+ EXPECT([matrix.name isEqualToString:@"CIColorMatrix"]);
412
+
413
+ // The red output's weights are the luminance weights, which is what
414
+ // grayscale(1) means -- and they are a *row* of the matrix, so a column-wise
415
+ // hand-off would put 0.2126 in all three vectors' X instead.
416
+ CIVector *red = [matrix valueForKey:@"inputRVector"];
417
+ EXPECT_NEAR((int)(red.X * 1000), 213, 2);
418
+ EXPECT_NEAR((int)(red.Y * 1000), 715, 2);
419
+ EXPECT_NEAR((int)(red.Z * 1000), 72, 2);
420
+ // Alpha is left alone.
421
+ CIVector *alpha = [matrix valueForKey:@"inputAVector"];
422
+ EXPECT_NEAR((int)alpha.W, 1, 0);
423
+ }
424
+ }
425
+
426
+ // invert(1) is a scale and a translate, and the translate is the half a
427
+ // matrix-only hand-off drops.
428
+ TEST(filter_an_offset_reaches_core_image_as_the_bias) {
429
+ @autoreleasepool {
430
+ RnAppKitView *view = [RnAppKitView viewWithTag:1];
431
+ using facebook::react::FilterType;
432
+ const RnAppKitFilters filters = resolvedFilters({filterFunction(FilterType::Invert, 1.0)});
433
+ [view setRnFilters:&filters];
434
+
435
+ CIFilter *matrix = view.rnFilters.firstObject;
436
+ CIVector *bias = [matrix valueForKey:@"inputBiasVector"];
437
+ EXPECT_NEAR((int)(bias.X * 100), 100, 1);
438
+ EXPECT_NEAR((int)(bias.Y * 100), 100, 1);
439
+ EXPECT_NEAR((int)(bias.Z * 100), 100, 1);
440
+ // And the scale is -1, so black comes out white rather than staying black.
441
+ CIVector *red = [matrix valueForKey:@"inputRVector"];
442
+ EXPECT_NEAR((int)(red.X * 100), -100, 1);
443
+ }
444
+ }
445
+
446
+ TEST(filter_a_blur_becomes_a_gaussian_of_half_the_radius) {
447
+ @autoreleasepool {
448
+ RnAppKitView *view = [RnAppKitView viewWithTag:1];
449
+ using facebook::react::FilterType;
450
+ const RnAppKitFilters filters = resolvedFilters({filterFunction(FilterType::Blur, 8.0)});
451
+ [view setRnFilters:&filters];
452
+
453
+ EXPECT_EQ((long)view.rnFilters.count, 1L);
454
+ CIFilter *blur = view.rnFilters.firstObject;
455
+ EXPECT([blur.name isEqualToString:@"CIGaussianBlur"]);
456
+ EXPECT_NEAR([[blur valueForKey:@"inputRadius"] intValue], 4, 0);
457
+ }
458
+ }
459
+
460
+ // `opacity()` is the layer's own opacity rather than a filter pass, which is
461
+ // cheaper and is what the GTK side does with its opacity node. It multiplies the
462
+ // view's own opacity, CSS letting an app ask in both places.
463
+ TEST(filter_opacity_multiplies_the_views_own) {
464
+ @autoreleasepool {
465
+ RnAppKitView *view = [RnAppKitView viewWithTag:1];
466
+ [view setRnOpacity:0.5];
467
+ using facebook::react::FilterType;
468
+ const RnAppKitFilters filters = resolvedFilters({filterFunction(FilterType::Opacity, 0.5)});
469
+ [view setRnFilters:&filters];
470
+
471
+ EXPECT_EQ((long)view.rnFilters.count, 0L);
472
+ EXPECT_NEAR((int)(view.layer.opacity * 100), 25, 1);
473
+
474
+ // And taken away again, which puts the view's own opacity back rather than
475
+ // leaving it at a quarter.
476
+ [view setRnFilters:nullptr];
477
+ EXPECT_NEAR((int)(view.layer.opacity * 100), 50, 1);
478
+ }
479
+ }
480
+
481
+ TEST(filter_is_reported_in_the_tree) {
482
+ @autoreleasepool {
483
+ RnAppKitView *view = [RnAppKitView viewWithTag:1];
484
+ EXPECT(![[view describeTree] containsString:@"filter="]);
485
+
486
+ using facebook::react::FilterType;
487
+ const RnAppKitFilters filters = resolvedFilters(
488
+ {filterFunction(FilterType::Grayscale, 1.0), filterFunction(FilterType::Blur, 8.0)});
489
+ [view setRnFilters:&filters];
490
+
491
+ // Spelled as GTK spells it, probe colour and all: (1, 0.5, 0.25) greyed is
492
+ // 0.588, which is 0x96 in every channel.
493
+ const std::string described = [view describeTree].UTF8String;
494
+ EXPECT(described.find("filter=(probe=#969696ff,blur=8)") != std::string::npos);
495
+ }
496
+ }
497
+
498
+ // `mixBlendMode`, which on macOS is one Core Animation property: a Core Image
499
+ // blend-mode filter set as the layer's `compositingFilter`, which blends the
500
+ // layer with whatever is beneath it in the layer tree.
501
+ //
502
+ // **No pixels, for the reason box shadows and filters have none here**, and it is
503
+ // the same measurement: compositing happens in the window server, and
504
+ // `-[CALayer renderInContext:]` -- which is what this project's snapshots use --
505
+ // composites nothing, so a blended layer draws exactly as an unblended one in a
506
+ // snapshot. The GTK side has the render tree and asserts the blended pixels node
507
+ // by node; here the observable is the filter the layer was given.
508
+ namespace {
509
+
510
+ RnAppKitView *blendedView(NSString *keyword) {
511
+ RnAppKitView *view = [RnAppKitView viewWithTag:1];
512
+ [view setRnFrameX:0 y:0 width:100 height:50];
513
+ [view setRnBlendModeName:keyword];
514
+ return view;
515
+ }
516
+
517
+ NSString *compositingFilterName(RnAppKitView *view) {
518
+ id filter = view.layer.compositingFilter;
519
+ if (![filter isKindOfClass:[CIFilter class]]) {
520
+ return nil;
521
+ }
522
+ return ((CIFilter *)filter).name;
523
+ }
524
+
525
+ } // namespace
526
+
527
+ TEST(blend_mode_becomes_a_core_image_compositing_filter) {
528
+ @autoreleasepool {
529
+ RnAppKitView *view = blendedView(@"multiply");
530
+ EXPECT([view.rnBlendModeName isEqualToString:@"multiply"]);
531
+ EXPECT([view.rnBlendFilterName isEqualToString:@"CIMultiplyBlendMode"]);
532
+ // And it reached the layer as a filter object, which is the half a stored
533
+ // property cannot show. `compositingFilter` is typed `id`: a string is what
534
+ // React Native's iOS half sets, and is undocumented.
535
+ EXPECT([compositingFilterName(view) isEqualToString:@"CIMultiplyBlendMode"]);
536
+ }
537
+ }
538
+
539
+ // Every keyword, and that Core Image has a filter for each: the mapping is
540
+ // sixteen lines of table, which is sixteen chances to paste the wrong name, and a
541
+ // name Core Image does not know makes `filterWithName:` return nil -- a view that
542
+ // silently stops blending rather than anything louder.
543
+ TEST(blend_mode_every_css_keyword_has_a_filter_core_image_knows) {
544
+ @autoreleasepool {
545
+ NSArray<NSString *> *keywords = @[
546
+ @"multiply", @"screen", @"overlay", @"darken", @"lighten", @"color-dodge",
547
+ @"color-burn", @"hard-light", @"soft-light", @"difference", @"exclusion",
548
+ @"hue", @"saturation", @"color", @"luminosity", @"plus-lighter"
549
+ ];
550
+ for (NSString *keyword in keywords) {
551
+ RnAppKitView *view = blendedView(keyword);
552
+ EXPECT(view.rnBlendFilterName != nil);
553
+ EXPECT([CIFilter filterWithName:view.rnBlendFilterName] != nil);
554
+ EXPECT(compositingFilterName(view) != nil);
555
+ }
556
+
557
+ // The one GSK has no node for, which this host does have: CSS's plus-lighter
558
+ // is clamped addition, and that is linear dodge.
559
+ EXPECT([blendedView(@"plus-lighter").rnBlendFilterName
560
+ isEqualToString:@"CILinearDodgeBlendMode"]);
561
+ // And two that are easy to swap for each other.
562
+ EXPECT([blendedView(@"color-dodge").rnBlendFilterName
563
+ isEqualToString:@"CIColorDodgeBlendMode"]);
564
+ EXPECT([blendedView(@"color-burn").rnBlendFilterName
565
+ isEqualToString:@"CIColorBurnBlendMode"]);
566
+ }
567
+ }
568
+
569
+ // `mixBlendMode: 'normal'` arrives as no keyword at all, and a view that was
570
+ // blending has to stop: a compositing filter left behind is a view that keeps
571
+ // blending for the rest of its life.
572
+ TEST(blend_mode_normal_leaves_the_layer_alone) {
573
+ @autoreleasepool {
574
+ RnAppKitView *view = blendedView(nil);
575
+ EXPECT(view.rnBlendModeName == nil);
576
+ EXPECT(view.layer.compositingFilter == nil);
577
+
578
+ [view setRnBlendModeName:@"overlay"];
579
+ EXPECT(view.layer.compositingFilter != nil);
580
+
581
+ [view setRnBlendModeName:nil];
582
+ EXPECT(view.layer.compositingFilter == nil);
583
+ EXPECT(view.rnBlendModeName == nil);
584
+ }
585
+ }
586
+
587
+ // A keyword this host has no filter for leaves the layer unblended, and keeps
588
+ // the keyword: the dump says what the app asked for. Nothing in React Native can
589
+ // send one today, which is exactly why it is worth pinning -- the enum is theirs
590
+ // to extend.
591
+ TEST(blend_mode_an_unknown_keyword_is_kept_and_not_applied) {
592
+ @autoreleasepool {
593
+ RnAppKitView *view = blendedView(@"plus-darker");
594
+ EXPECT([view.rnBlendModeName isEqualToString:@"plus-darker"]);
595
+ EXPECT(view.rnBlendFilterName == nil);
596
+ EXPECT(view.layer.compositingFilter == nil);
597
+ }
598
+ }
599
+
600
+ // And in the tree dump, spelled as GTK spells it, which is what lets one
601
+ // scenario check the wiring on both hosts.
602
+ TEST(blend_mode_is_reported_in_the_tree) {
603
+ @autoreleasepool {
604
+ RnAppKitView *plain = blendedView(nil);
605
+ EXPECT([plain describeTree].UTF8String != nullptr);
606
+ EXPECT(std::string([plain describeTree].UTF8String).find("blend=") == std::string::npos);
607
+
608
+ RnAppKitView *view = blendedView(@"color-dodge");
609
+ const std::string described = [view describeTree].UTF8String;
610
+ EXPECT(described.find("blend=color-dodge") != std::string::npos);
611
+ }
612
+ }
613
+
614
+ // `filter: drop-shadow(...)`, which on this host is the layer's own shadow: with
615
+ // no `shadowPath`, Core Animation casts it from the layer's alpha, which is what
616
+ // the filter function means and what the box shadows beside it -- each with a
617
+ // path -- cannot do.
618
+ //
619
+ // No pixels, for the reason the rest of this file has none: `renderInContext:`
620
+ // draws no shadow at all, measured on a bare layer. The GTK side asserts the
621
+ // picture, including that the shadow has a hole where the view does.
622
+ namespace {
623
+
624
+ RnAppKitView *dropShadowed(CGFloat dx, CGFloat dy, CGFloat sigma, CGFloat alpha) {
625
+ RnAppKitView *view = [RnAppKitView viewWithTag:1];
626
+ [view setRnFrameX:0 y:0 width:60 height:40];
627
+ RnAppKitFilters filters{};
628
+ filters.opacity = 1;
629
+ RnAppKitFilterShadow shadow{};
630
+ shadow.dx = dx;
631
+ shadow.dy = dy;
632
+ shadow.standardDeviation = sigma;
633
+ shadow.color[3] = alpha;
634
+ filters.shadows = &shadow;
635
+ filters.shadowCount = 1;
636
+ [view setRnFilters:&filters];
637
+ return view;
638
+ }
639
+
640
+ } // namespace
641
+
642
+ TEST(filter_a_drop_shadow_becomes_the_layers_own_shadow) {
643
+ @autoreleasepool {
644
+ RnAppKitView *view = dropShadowed(4, 6, 3, 0.5);
645
+
646
+ // The standard deviation unchanged: CALayer's shadowRadius is one, where
647
+ // GSK's radius is twice one. A doubling here would be a shadow twice as soft
648
+ // as the GTK host's.
649
+ EXPECT_EQ((int)(view.layer.shadowRadius * 10), 30);
650
+ // The alpha is the layer's opacity rather than the colour's, so the two
651
+ // cannot multiply together.
652
+ EXPECT_EQ((int)(view.layer.shadowOpacity * 100), 50);
653
+ EXPECT(view.layer.shadowColor != nil);
654
+ EXPECT_EQ((int)(CGColorGetAlpha(view.layer.shadowColor) * 100), 100);
655
+
656
+ // Down the screen is a positive dy, as CSS means it, and the layer is not
657
+ // flipped where this view is -- so the sign turns over.
658
+ EXPECT_EQ((int)view.layer.shadowOffset.width, 4);
659
+ EXPECT_EQ((int)view.layer.shadowOffset.height, -6);
660
+
661
+ // And no path, which is what makes it follow the alpha rather than the box.
662
+ EXPECT(view.layer.shadowPath == nil);
663
+ }
664
+ }
665
+
666
+ // It comes off again. This is the failure the `opacity()` filter actually had
667
+ // before it was caught: a layer property set by a filter and never cleared
668
+ // leaves the view wrong for the rest of its life.
669
+ TEST(filter_a_drop_shadow_comes_off_again) {
670
+ @autoreleasepool {
671
+ RnAppKitView *view = dropShadowed(4, 6, 3, 0.5);
672
+ EXPECT(view.layer.shadowOpacity > 0);
673
+
674
+ [view setRnFilters:nullptr];
675
+ EXPECT_EQ((int)(view.layer.shadowOpacity * 100), 0);
676
+ EXPECT(view.layer.shadowColor == nil);
677
+ EXPECT_EQ((int)view.layer.shadowRadius, 0);
678
+ EXPECT_EQ((int)view.rnFilterShadow.standardDeviation, 0);
679
+
680
+ // And a filter list without a drop shadow clears it too, which is the case
681
+ // that goes through the other branch.
682
+ RnAppKitView *second = dropShadowed(4, 6, 3, 0.5);
683
+ using facebook::react::FilterType;
684
+ const RnAppKitFilters greyscale = resolvedFilters({filterFunction(FilterType::Grayscale, 1.0)});
685
+ [second setRnFilters:&greyscale];
686
+ EXPECT_EQ((int)(second.layer.shadowOpacity * 100), 0);
687
+ EXPECT(second.rnFilters.count == 1);
688
+ }
689
+ }
690
+
691
+ // The first `dropShadow()` is the view's own layer's, which is the half of the
692
+ // arrangement worth pinning separately: it is the one cast from the content's
693
+ // *alpha* rather than from the view's box, which is what makes a drop shadow a
694
+ // drop shadow, and CSS applies a filter list left to right so the first is the
695
+ // one nearest the content. The rest are layers of their own and the test below
696
+ // is theirs.
697
+ TEST(filter_the_first_drop_shadow_is_the_views_own_layers) {
698
+ @autoreleasepool {
699
+ RnAppKitView *view = [RnAppKitView viewWithTag:1];
700
+ [view setRnFrameX:0 y:0 width:60 height:40];
701
+
702
+ RnAppKitFilterShadow shadows[2]{};
703
+ shadows[0].dx = 2;
704
+ shadows[0].standardDeviation = 1;
705
+ shadows[0].color[3] = 1;
706
+ shadows[1].dx = 20;
707
+ shadows[1].standardDeviation = 10;
708
+ shadows[1].color[3] = 1;
709
+
710
+ RnAppKitFilters filters{};
711
+ filters.opacity = 1;
712
+ filters.shadows = shadows;
713
+ filters.shadowCount = 2;
714
+ [view setRnFilters:&filters];
715
+
716
+ EXPECT_EQ((int)view.layer.shadowOffset.width, 2);
717
+ EXPECT_EQ((int)view.layer.shadowRadius, 1);
718
+ }
719
+ }
720
+
721
+ // Both `dropShadow()` entries in a filter list are drawn, which is the half of
722
+ // the list this host drops. CSS applies a filter list left to right, each
723
+ // function working on what the one before it produced, so two drop shadows are
724
+ // a shadow of the content and then a shadow of that -- not a choice between the
725
+ // two. The pair that an app actually writes is a tight dark shadow for the edge
726
+ // and a wide soft one for the lift, and with only the first drawn the view
727
+ // still looks deliberate: the warning goes to the log, nothing on screen says a
728
+ // shadow is missing, and the design it was copied from is the only place the
729
+ // second one survives.
730
+ //
731
+ // A CALayer carries one shadow, so the two cannot both live on the view's own
732
+ // layer -- which is why this looks at every layer in the view's own tree rather
733
+ // than at `self.layer` alone, the way the box shadows beside it already take a
734
+ // layer per shadow. Which layer each lands on is an implementation's choice;
735
+ // that both are there is not. Layer properties and no pixels, for the reason
736
+ // the rest of this file has none: Core Animation composites a shadow and
737
+ // `renderInContext:` draws nothing of it.
738
+ //
739
+ // `filter_the_first_drop_shadow_is_the_views_own_layers` above pins the half
740
+ // of this that is cast from the content's alpha; this one is about the rest of
741
+ // the list arriving at all.
742
+ namespace {
743
+
744
+ // One shadow found in a layer tree, in the layer's own terms.
745
+ struct LayerShadow {
746
+ CGFloat dx = 0;
747
+ CGFloat dy = 0;
748
+ CGFloat radius = 0;
749
+ float opacity = 0;
750
+ };
751
+
752
+ void collectShadows(CALayer *layer, std::vector<LayerShadow> &found) {
753
+ if (layer.shadowOpacity > 0) {
754
+ found.push_back(LayerShadow{layer.shadowOffset.width,
755
+ layer.shadowOffset.height,
756
+ layer.shadowRadius,
757
+ layer.shadowOpacity});
758
+ }
759
+ for (CALayer *sublayer in layer.sublayers) {
760
+ collectShadows(sublayer, found);
761
+ }
762
+ }
763
+
764
+ std::vector<LayerShadow> shadowsInLayerTree(RnAppKitView *view) {
765
+ std::vector<LayerShadow> found;
766
+ collectShadows(view.layer, found);
767
+ return found;
768
+ }
769
+
770
+ // Whether one of them is the shadow that was asked for, in the terms the other
771
+ // drop shadow tests use: the standard deviation crosses unchanged into
772
+ // `shadowRadius`, the colour's alpha becomes `shadowOpacity`, and the offset is
773
+ // CSS's with the sign of dy turned over, the layer not being flipped where the
774
+ // view is.
775
+ bool carriesShadow(const std::vector<LayerShadow> &found,
776
+ CGFloat dx,
777
+ CGFloat dy,
778
+ CGFloat standardDeviation,
779
+ float opacity) {
780
+ for (const LayerShadow &shadow : found) {
781
+ if ((int)shadow.dx == (int)dx && (int)shadow.dy == (int)(-dy) &&
782
+ (int)(shadow.radius * 10) == (int)(standardDeviation * 10) &&
783
+ (int)(shadow.opacity * 100) == (int)(opacity * 100)) {
784
+ return true;
785
+ }
786
+ }
787
+ return false;
788
+ }
789
+
790
+ // A view asking for two unmistakably different drop shadows, or for the first
791
+ // of them alone -- the same two numbers either way, so the only difference
792
+ // between the two views is the entry that is meant to be dropped.
793
+ RnAppKitView *twiceDropShadowed(bool bothEntries) {
794
+ RnAppKitView *view = [RnAppKitView viewWithTag:1];
795
+ [view setRnFrameX:0 y:0 width:60 height:40];
796
+
797
+ RnAppKitFilterShadow shadows[2]{};
798
+ shadows[0].dx = 2;
799
+ shadows[0].dy = 3;
800
+ shadows[0].standardDeviation = 1;
801
+ shadows[0].color[3] = 1;
802
+ shadows[1].dx = 20;
803
+ shadows[1].dy = 6;
804
+ shadows[1].standardDeviation = 10;
805
+ shadows[1].color[3] = 0.5;
806
+
807
+ RnAppKitFilters filters{};
808
+ filters.opacity = 1;
809
+ filters.shadows = shadows;
810
+ filters.shadowCount = bothEntries ? 2 : 1;
811
+ [view setRnFilters:&filters];
812
+ return view;
813
+ }
814
+
815
+ } // namespace
816
+
817
+ TEST(filter_two_drop_shadows_are_both_drawn) {
818
+ @autoreleasepool {
819
+ const std::vector<LayerShadow> both = shadowsInLayerTree(twiceDropShadowed(true));
820
+
821
+ // Two shadows, each with its own numbers: a view that kept only the last,
822
+ // or that averaged the pair into one middling shadow, fails here as surely
823
+ // as one that keeps only the first.
824
+ EXPECT_EQ((long)both.size(), 2L);
825
+ EXPECT(carriesShadow(both, 2, 3, 1, 1.0F));
826
+ EXPECT(carriesShadow(both, 20, 6, 10, 0.5F));
827
+
828
+ // And asking for two is not the same as asking for the first alone, which
829
+ // is what dropping the rest of the list comes to.
830
+ const std::vector<LayerShadow> firstOnly = shadowsInLayerTree(twiceDropShadowed(false));
831
+ EXPECT_EQ((long)firstOnly.size(), 1L);
832
+ EXPECT(carriesShadow(firstOnly, 2, 3, 1, 1.0F));
833
+ EXPECT(!carriesShadow(firstOnly, 20, 6, 10, 0.5F));
834
+ EXPECT(both.size() != firstOnly.size());
835
+ }
836
+ }
837
+
838
+ TEST(filter_a_drop_shadow_is_reported_in_the_tree) {
839
+ @autoreleasepool {
840
+ RnAppKitView *view = dropShadowed(4, 6, 3, 0.5);
841
+ // Spelled as GTK spells it, and with the standard deviation React Native
842
+ // parsed rather than either platform's own radius.
843
+ const std::string described = [view describeTree].UTF8String;
844
+ EXPECT(described.find("filter=(shadow=(4,6,3,#00000080))") != std::string::npos);
845
+ }
846
+ }