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
|
@@ -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
|
+
}
|