basalt-appkit 0.0.0-rc5 → 0.2.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 +582 -12
- package/native/appkit/AppKitScrollView.h +48 -0
- package/native/appkit/AppKitScrollView.mm +210 -0
- package/native/appkit/AppKitSkiaPeer.h +15 -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 +28 -3
- 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
|
@@ -0,0 +1,975 @@
|
|
|
1
|
+
// Tests for what a border is drawn as: four filled edges, or one dashed stroke.
|
|
2
|
+
//
|
|
3
|
+
// Against a real bitmap rather than against the properties the view stored, for
|
|
4
|
+
// the reason the GTK side walks its render tree: `borderStyle` is a prop that is
|
|
5
|
+
// easy to store and forget to draw, and a view that remembered "dashed" and
|
|
6
|
+
// painted a solid line would pass any test that asked it what it holds.
|
|
7
|
+
//
|
|
8
|
+
// What is counted is runs of ink along the top edge. A solid border is one run
|
|
9
|
+
// from corner to corner; a dashed one is several, and a dotted one is more of
|
|
10
|
+
// them than a dashed one at the same width, because the pattern is scaled to the
|
|
11
|
+
// width in both cases and a dot's period is shorter.
|
|
12
|
+
|
|
13
|
+
#include "TestHarness.h"
|
|
14
|
+
|
|
15
|
+
#import "RnAppKitView.h"
|
|
16
|
+
|
|
17
|
+
#include <sstream>
|
|
18
|
+
#include <vector>
|
|
19
|
+
|
|
20
|
+
namespace {
|
|
21
|
+
|
|
22
|
+
// The red channel of the view drawn onto white, row-major.
|
|
23
|
+
std::vector<unsigned char> drawnPixels(RnAppKitView *view, CGSize size) {
|
|
24
|
+
CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
|
25
|
+
CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width, (size_t)size.height,
|
|
26
|
+
8, 0, space, kCGImageAlphaPremultipliedLast);
|
|
27
|
+
CGColorSpaceRelease(space);
|
|
28
|
+
CGContextSetRGBFillColor(context, 1, 1, 1, 1);
|
|
29
|
+
CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
|
|
30
|
+
CGContextTranslateCTM(context, 0, size.height);
|
|
31
|
+
CGContextScaleCTM(context, 1, -1);
|
|
32
|
+
|
|
33
|
+
NSGraphicsContext *previous = NSGraphicsContext.currentContext;
|
|
34
|
+
NSGraphicsContext.currentContext = [NSGraphicsContext graphicsContextWithCGContext:context
|
|
35
|
+
flipped:YES];
|
|
36
|
+
[view drawRect:NSMakeRect(0, 0, size.width, size.height)];
|
|
37
|
+
NSGraphicsContext.currentContext = previous;
|
|
38
|
+
|
|
39
|
+
auto *bytes = static_cast<unsigned char *>(CGBitmapContextGetData(context));
|
|
40
|
+
const size_t stride = CGBitmapContextGetBytesPerRow(context);
|
|
41
|
+
std::vector<unsigned char> red((size_t)size.width * (size_t)size.height);
|
|
42
|
+
for (size_t y = 0; y < (size_t)size.height; y++) {
|
|
43
|
+
for (size_t x = 0; x < (size_t)size.width; x++) {
|
|
44
|
+
red[y * (size_t)size.width + x] = bytes[y * stride + x * 4];
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
CGContextRelease(context);
|
|
48
|
+
return red;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// How many separate runs of ink there are along one row, and how much of the row
|
|
52
|
+
// is inked at all. One run spanning the width is a solid line.
|
|
53
|
+
struct Runs {
|
|
54
|
+
int count{0};
|
|
55
|
+
int inked{0};
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
Runs runsAlong(RnAppKitView *view, CGSize size, int row) {
|
|
59
|
+
const std::vector<unsigned char> pixels = drawnPixels(view, size);
|
|
60
|
+
Runs runs;
|
|
61
|
+
bool inside = false;
|
|
62
|
+
for (int x = 0; x < (int)size.width; x++) {
|
|
63
|
+
const bool ink = pixels[(size_t)row * (size_t)size.width + (size_t)x] < 128;
|
|
64
|
+
if (ink) {
|
|
65
|
+
runs.inked++;
|
|
66
|
+
if (!inside) {
|
|
67
|
+
runs.count++;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
inside = ink;
|
|
71
|
+
}
|
|
72
|
+
return runs;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
RnAppKitView *bordered(CGFloat width, RnAppKitBorderStyle style, CGSize size) {
|
|
76
|
+
RnAppKitView *view = [RnAppKitView viewWithTag:1];
|
|
77
|
+
[view setRnFrameX:0 y:0 width:size.width height:size.height];
|
|
78
|
+
const CGFloat widths[4] = {width, width, width, width};
|
|
79
|
+
CGFloat colors[16];
|
|
80
|
+
for (int edge = 0; edge < 4; edge++) {
|
|
81
|
+
colors[edge * 4 + 0] = 0;
|
|
82
|
+
colors[edge * 4 + 1] = 0;
|
|
83
|
+
colors[edge * 4 + 2] = 0;
|
|
84
|
+
colors[edge * 4 + 3] = 1;
|
|
85
|
+
}
|
|
86
|
+
[view setRnBorderWidths:widths colors:colors];
|
|
87
|
+
[view setRnBorderStyle:style];
|
|
88
|
+
return view;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
} // namespace
|
|
92
|
+
|
|
93
|
+
TEST(border_solid_is_one_unbroken_line) {
|
|
94
|
+
@autoreleasepool {
|
|
95
|
+
const CGSize size = CGSizeMake(100, 60);
|
|
96
|
+
const Runs runs = runsAlong(bordered(4, RnAppKitBorderStyleSolid, size), size, 2);
|
|
97
|
+
EXPECT_EQ(runs.count, 1);
|
|
98
|
+
EXPECT_EQ(runs.inked, 100);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
TEST(border_dashed_leaves_gaps) {
|
|
103
|
+
@autoreleasepool {
|
|
104
|
+
const CGSize size = CGSizeMake(100, 60);
|
|
105
|
+
const Runs runs = runsAlong(bordered(4, RnAppKitBorderStyleDashed, size), size, 2);
|
|
106
|
+
// 12 on, 8 off at this width, so five dashes across a hundred points. What
|
|
107
|
+
// is asserted is that there is more than one and that the row is not full:
|
|
108
|
+
// the exact count depends on where the path starts its phase, which is not
|
|
109
|
+
// worth pinning.
|
|
110
|
+
EXPECT(runs.count > 2);
|
|
111
|
+
EXPECT(runs.inked < 100);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// Dotted is not just dashed with a different comment: the pattern is shorter, so
|
|
116
|
+
// there are more runs along the same edge at the same width.
|
|
117
|
+
TEST(border_dotted_is_denser_than_dashed) {
|
|
118
|
+
@autoreleasepool {
|
|
119
|
+
const CGSize size = CGSizeMake(100, 60);
|
|
120
|
+
const Runs dashed = runsAlong(bordered(4, RnAppKitBorderStyleDashed, size), size, 2);
|
|
121
|
+
const Runs dotted = runsAlong(bordered(4, RnAppKitBorderStyleDotted, size), size, 2);
|
|
122
|
+
EXPECT(dotted.count > dashed.count);
|
|
123
|
+
// And a dot is round rather than a stretch of line, so less of the row is
|
|
124
|
+
// inked than the dashes ink.
|
|
125
|
+
EXPECT(dotted.inked < dashed.inked);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// The stroke replaces the four filled edges rather than joining them. Two would
|
|
130
|
+
// paint the outline twice and leave the dashes sitting on a solid line, which
|
|
131
|
+
// looks like a solid border and is the mistake this guards.
|
|
132
|
+
TEST(border_dashed_does_not_also_paint_the_solid_edges) {
|
|
133
|
+
@autoreleasepool {
|
|
134
|
+
const CGSize size = CGSizeMake(100, 60);
|
|
135
|
+
// The row through the middle of the left edge's band, where a filled left
|
|
136
|
+
// edge would be continuous from top to bottom.
|
|
137
|
+
const std::vector<unsigned char> pixels =
|
|
138
|
+
drawnPixels(bordered(4, RnAppKitBorderStyleDashed, size), size);
|
|
139
|
+
int inked = 0;
|
|
140
|
+
for (int y = 0; y < (int)size.height; y++) {
|
|
141
|
+
if (pixels[(size_t)y * (size_t)size.width + 2] < 128) {
|
|
142
|
+
inked++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
EXPECT(inked > 0);
|
|
146
|
+
EXPECT(inked < (int)size.height);
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// A style with no width is not a border. Stroking here would put a line around
|
|
151
|
+
// every view that mentioned `borderStyle` and set no `borderWidth`.
|
|
152
|
+
TEST(border_a_style_with_no_width_draws_nothing) {
|
|
153
|
+
@autoreleasepool {
|
|
154
|
+
const CGSize size = CGSizeMake(100, 60);
|
|
155
|
+
RnAppKitView *view = [RnAppKitView viewWithTag:1];
|
|
156
|
+
[view setRnFrameX:0 y:0 width:size.width height:size.height];
|
|
157
|
+
[view setRnBorderStyle:RnAppKitBorderStyleDashed];
|
|
158
|
+
EXPECT_EQ(runsAlong(view, size, 2).inked, 0);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// And the style is in the tree dump, which is what makes the wiring testable:
|
|
163
|
+
// the prop is read in AppKitMountingManager, and every test above sets it on the
|
|
164
|
+
// view by hand. Spelled as the GTK side spells it, so the cross-host diff can
|
|
165
|
+
// compare them.
|
|
166
|
+
TEST(border_style_is_reported_in_the_tree) {
|
|
167
|
+
@autoreleasepool {
|
|
168
|
+
const CGSize size = CGSizeMake(100, 60);
|
|
169
|
+
EXPECT(![[bordered(4, RnAppKitBorderStyleSolid, size) describeTree]
|
|
170
|
+
containsString:@"border-style="]);
|
|
171
|
+
EXPECT([[bordered(4, RnAppKitBorderStyleDashed, size) describeTree]
|
|
172
|
+
containsString:@"border-style=dashed"]);
|
|
173
|
+
EXPECT([[bordered(4, RnAppKitBorderStyleDotted, size) describeTree]
|
|
174
|
+
containsString:@"border-style=dotted"]);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// `boxShadow`.
|
|
179
|
+
//
|
|
180
|
+
// Two things are checked, because they fail differently: what each shadow's layer
|
|
181
|
+
// carries -- the path, the blur, the colour -- and where that layer lives, which
|
|
182
|
+
// is what decides whether `overflow: 'hidden'` clips the shadow away.
|
|
183
|
+
//
|
|
184
|
+
// **No pixels here, and that is measured rather than assumed.** A layer's shadow
|
|
185
|
+
// is drawn by Core Animation during compositing; `-[CALayer renderInContext:]`
|
|
186
|
+
// does not draw it at all, which a probe confirmed on a bare layer with a
|
|
187
|
+
// shadowPath and nothing else -- every pixel came back white, inside the path as
|
|
188
|
+
// well as outside. That is also the method `AppKitSnapshot.mm` uses, so a box
|
|
189
|
+
// shadow is invisible to this project's own snapshots on macOS, and a window
|
|
190
|
+
// capture through CGWindowList needs a permission no CI runner grants. The GTK
|
|
191
|
+
// side has the render tree, which is why its half of this feature is checked
|
|
192
|
+
// node by node; here the layers and their paths are the observable thing.
|
|
193
|
+
namespace {
|
|
194
|
+
|
|
195
|
+
RnAppKitBoxShadow outsetShadow(CGFloat dx, CGFloat dy, CGFloat blur, CGFloat spread) {
|
|
196
|
+
RnAppKitBoxShadow shadow{};
|
|
197
|
+
shadow.dx = dx;
|
|
198
|
+
shadow.dy = dy;
|
|
199
|
+
shadow.blur = blur;
|
|
200
|
+
shadow.spread = spread;
|
|
201
|
+
shadow.color[3] = 1;
|
|
202
|
+
shadow.inset = false;
|
|
203
|
+
return shadow;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
// A view inside a parent, which is where a view with an outset shadow has to be
|
|
207
|
+
// for the shadow to have anywhere to go.
|
|
208
|
+
RnAppKitView *shadowed(const RnAppKitBoxShadow *shadows, NSInteger count, CGRect frame) {
|
|
209
|
+
RnAppKitView *parent = [RnAppKitView viewWithTag:1];
|
|
210
|
+
[parent setRnFrameX:0 y:0 width:200 height:200];
|
|
211
|
+
RnAppKitView *view = [RnAppKitView viewWithTag:2];
|
|
212
|
+
[parent insertRnChild:view atIndex:0];
|
|
213
|
+
[view setRnFrameX:frame.origin.x y:frame.origin.y
|
|
214
|
+
width:frame.size.width height:frame.size.height];
|
|
215
|
+
[view setRnBoxShadows:shadows count:count];
|
|
216
|
+
return view;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
} // namespace
|
|
220
|
+
|
|
221
|
+
TEST(box_shadow_becomes_a_layer_carrying_its_own_numbers) {
|
|
222
|
+
@autoreleasepool {
|
|
223
|
+
const RnAppKitBoxShadow shadow = outsetShadow(2, 4, 8, 1);
|
|
224
|
+
RnAppKitView *view = shadowed(&shadow, 1, CGRectMake(50, 50, 100, 60));
|
|
225
|
+
|
|
226
|
+
EXPECT_EQ((long)view.rnBoxShadowCount, 1L);
|
|
227
|
+
EXPECT_EQ((long)view.rnBoxShadowLayers.count, 1L);
|
|
228
|
+
CALayer *layer = view.rnBoxShadowLayers.firstObject;
|
|
229
|
+
|
|
230
|
+
// Half the blur radius, which is CSS's radius as a gaussian sigma.
|
|
231
|
+
EXPECT_NEAR((int)(layer.shadowRadius * 100), 400, 1);
|
|
232
|
+
EXPECT_NEAR((int)layer.shadowOpacity, 1, 0);
|
|
233
|
+
// The offset is in the path, not in shadowOffset: this view is flipped, and
|
|
234
|
+
// a path in its own coordinates comes out pointing the right way.
|
|
235
|
+
EXPECT_EQ((int)layer.shadowOffset.width, 0);
|
|
236
|
+
EXPECT_EQ((int)layer.shadowOffset.height, 0);
|
|
237
|
+
|
|
238
|
+
// The path is the box, grown by the spread and moved by the offset. Checking
|
|
239
|
+
// the bounding box catches an offset on the wrong axis and a spread read as a
|
|
240
|
+
// blur, which both draw a plausible shadow.
|
|
241
|
+
const CGRect box = CGPathGetBoundingBox(layer.shadowPath);
|
|
242
|
+
EXPECT_EQ((int)box.origin.x, 1); // -spread + dx
|
|
243
|
+
EXPECT_EQ((int)box.origin.y, 3); // -spread + dy
|
|
244
|
+
EXPECT_EQ((int)box.size.width, 102);
|
|
245
|
+
EXPECT_EQ((int)box.size.height, 62);
|
|
246
|
+
|
|
247
|
+
// And it is masked, or the shadow would show through a translucent box.
|
|
248
|
+
EXPECT(layer.mask != nil);
|
|
249
|
+
EXPECT([((CAShapeLayer *)layer.mask).fillRule isEqualToString:kCAFillRuleEvenOdd]);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Where the layer lives. Inside the view normally, which is where it composites
|
|
254
|
+
// exactly as the view does.
|
|
255
|
+
TEST(box_shadow_an_outset_shadow_sits_inside_the_view_that_casts_it) {
|
|
256
|
+
@autoreleasepool {
|
|
257
|
+
const RnAppKitBoxShadow shadow = outsetShadow(0, 4, 8, 0);
|
|
258
|
+
RnAppKitView *view = shadowed(&shadow, 1, CGRectMake(50, 50, 100, 60));
|
|
259
|
+
|
|
260
|
+
CALayer *layer = view.rnBoxShadowLayers.firstObject;
|
|
261
|
+
EXPECT(layer != nil);
|
|
262
|
+
EXPECT(layer.superlayer == view.layer);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// And in the parent when the view clips itself, which is the case the obvious
|
|
267
|
+
// arrangement loses entirely: masksToBounds clips a sublayer, and CSS does not
|
|
268
|
+
// clip an element's own shadow for `overflow: 'hidden'`.
|
|
269
|
+
TEST(box_shadow_a_clipping_view_casts_its_shadow_into_the_parent) {
|
|
270
|
+
@autoreleasepool {
|
|
271
|
+
const RnAppKitBoxShadow shadow = outsetShadow(0, 4, 8, 0);
|
|
272
|
+
RnAppKitView *view = shadowed(&shadow, 1, CGRectMake(50, 50, 100, 60));
|
|
273
|
+
[view setRnClipsChildren:YES];
|
|
274
|
+
|
|
275
|
+
CALayer *layer = view.rnBoxShadowLayers.firstObject;
|
|
276
|
+
EXPECT(layer != nil);
|
|
277
|
+
EXPECT(layer.superlayer == view.superview.layer);
|
|
278
|
+
// In the parent's coordinates, so the shadow is still where the view is.
|
|
279
|
+
EXPECT_EQ((int)layer.frame.origin.x, 50);
|
|
280
|
+
EXPECT_EQ((int)layer.frame.origin.y, 50);
|
|
281
|
+
|
|
282
|
+
// And back inside when the view stops clipping.
|
|
283
|
+
[view setRnClipsChildren:NO];
|
|
284
|
+
EXPECT(view.rnBoxShadowLayers.firstObject.superlayer == view.layer);
|
|
285
|
+
EXPECT(layer.superlayer == nil);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// The other way a view clips its own layer: radii too elliptical for CALayer's
|
|
290
|
+
// cornerRadius, which get a mask layer. Same consequence, same answer.
|
|
291
|
+
TEST(box_shadow_elliptical_radii_also_move_the_shadow_out) {
|
|
292
|
+
@autoreleasepool {
|
|
293
|
+
const RnAppKitBoxShadow shadow = outsetShadow(0, 4, 8, 0);
|
|
294
|
+
RnAppKitView *view = shadowed(&shadow, 1, CGRectMake(50, 50, 100, 60));
|
|
295
|
+
const CGFloat radii[8] = {20, 8, 20, 8, 20, 8, 20, 8};
|
|
296
|
+
[view setRnBorderRadii:radii];
|
|
297
|
+
|
|
298
|
+
EXPECT(view.layer.mask != nil);
|
|
299
|
+
EXPECT(view.rnBoxShadowLayers.firstObject.superlayer == view.superview.layer);
|
|
300
|
+
// The shadow follows the radii it is cast by, so its path is rounded too.
|
|
301
|
+
const CGRect box = CGPathGetBoundingBox(view.rnBoxShadowLayers.firstObject.shadowPath);
|
|
302
|
+
EXPECT_EQ((int)box.size.width, 100);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// An inset shadow is the other way round: inside the view, where clipping is
|
|
307
|
+
// right, and with the hole punched out of its path so the blur falls inward.
|
|
308
|
+
TEST(box_shadow_an_inset_shadow_sits_inside_the_view) {
|
|
309
|
+
@autoreleasepool {
|
|
310
|
+
RnAppKitBoxShadow shadow = outsetShadow(0, 2, 4, 0);
|
|
311
|
+
shadow.inset = true;
|
|
312
|
+
RnAppKitView *view = shadowed(&shadow, 1, CGRectMake(50, 50, 100, 60));
|
|
313
|
+
|
|
314
|
+
CALayer *layer = view.rnBoxShadowLayers.firstObject;
|
|
315
|
+
EXPECT(layer != nil);
|
|
316
|
+
EXPECT(layer.superlayer == view.layer);
|
|
317
|
+
// The path is bigger than the view, being the ring the shadow is cast from,
|
|
318
|
+
// and the mask keeps it inside.
|
|
319
|
+
const CGRect box = CGPathGetBoundingBox(layer.shadowPath);
|
|
320
|
+
EXPECT(box.size.width > 100);
|
|
321
|
+
EXPECT(layer.mask != nil);
|
|
322
|
+
// Not even-odd: the hole is a reversed subpath, so the winding punches it.
|
|
323
|
+
EXPECT(![((CAShapeLayer *)layer.mask).fillRule isEqualToString:kCAFillRuleEvenOdd]);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
TEST(box_shadow_every_shadow_in_the_list_becomes_a_layer) {
|
|
328
|
+
@autoreleasepool {
|
|
329
|
+
RnAppKitBoxShadow shadows[3] = {
|
|
330
|
+
outsetShadow(0, 1, 2, 0),
|
|
331
|
+
outsetShadow(0, 8, 24, -4),
|
|
332
|
+
outsetShadow(0, 1, 0, 0),
|
|
333
|
+
};
|
|
334
|
+
shadows[2].inset = true;
|
|
335
|
+
RnAppKitView *view = shadowed(shadows, 3, CGRectMake(50, 50, 100, 60));
|
|
336
|
+
|
|
337
|
+
EXPECT_EQ((long)view.rnBoxShadowCount, 3L);
|
|
338
|
+
EXPECT_EQ((long)view.rnBoxShadowLayers.count, 3L);
|
|
339
|
+
// Back to front: the first shadow in the list is the one on top, so the
|
|
340
|
+
// layers come out in the reverse of the list -- the inset one with no blur
|
|
341
|
+
// first, then the wide soft one, then the tight one.
|
|
342
|
+
EXPECT_NEAR((int)view.rnBoxShadowLayers[0].shadowRadius, 0, 0);
|
|
343
|
+
EXPECT_NEAR((int)view.rnBoxShadowLayers[1].shadowRadius, 12, 0);
|
|
344
|
+
EXPECT_NEAR((int)view.rnBoxShadowLayers[2].shadowRadius, 1, 0);
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
TEST(box_shadow_can_be_taken_away_again) {
|
|
349
|
+
@autoreleasepool {
|
|
350
|
+
const RnAppKitBoxShadow shadow = outsetShadow(0, 4, 8, 0);
|
|
351
|
+
RnAppKitView *view = shadowed(&shadow, 1, CGRectMake(50, 50, 100, 60));
|
|
352
|
+
CALayer *layer = view.rnBoxShadowLayers.firstObject;
|
|
353
|
+
|
|
354
|
+
[view setRnBoxShadows:nullptr count:0];
|
|
355
|
+
EXPECT_EQ((long)view.rnBoxShadowCount, 0L);
|
|
356
|
+
EXPECT_EQ((long)view.rnBoxShadowLayers.count, 0L);
|
|
357
|
+
// And it left the parent, or the shadow outlives the prop that asked for it.
|
|
358
|
+
EXPECT(layer.superlayer == nil);
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
// A view that is unmounted takes its shadow with it. The shadow lives in the
|
|
363
|
+
// parent, so this is the one way this arrangement can leak something visible.
|
|
364
|
+
TEST(box_shadow_leaves_the_parent_when_the_view_does) {
|
|
365
|
+
@autoreleasepool {
|
|
366
|
+
const RnAppKitBoxShadow shadow = outsetShadow(0, 4, 8, 0);
|
|
367
|
+
RnAppKitView *view = shadowed(&shadow, 1, CGRectMake(50, 50, 100, 60));
|
|
368
|
+
[view setRnClipsChildren:YES];
|
|
369
|
+
NSView *parent = view.superview;
|
|
370
|
+
CALayer *layer = view.rnBoxShadowLayers.firstObject;
|
|
371
|
+
// In the parent, because the view clips. AppKit has not attached the view's
|
|
372
|
+
// own layer yet -- it does that at the first display -- so the shadow is all
|
|
373
|
+
// there is to find there.
|
|
374
|
+
EXPECT(layer.superlayer == parent.layer);
|
|
375
|
+
|
|
376
|
+
[view removeFromSuperview];
|
|
377
|
+
EXPECT(layer.superlayer == nil);
|
|
378
|
+
EXPECT_EQ((long)view.rnBoxShadowLayers.count, 0L);
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
TEST(box_shadow_with_no_colour_is_not_drawn) {
|
|
383
|
+
@autoreleasepool {
|
|
384
|
+
RnAppKitBoxShadow shadow = outsetShadow(0, 4, 8, 0);
|
|
385
|
+
shadow.color[3] = 0;
|
|
386
|
+
RnAppKitView *view = shadowed(&shadow, 1, CGRectMake(50, 50, 100, 60));
|
|
387
|
+
|
|
388
|
+
// Still one prop, no layer: what is skipped is the painting, not the prop.
|
|
389
|
+
EXPECT_EQ((long)view.rnBoxShadowCount, 1L);
|
|
390
|
+
EXPECT_EQ((long)view.rnBoxShadowLayers.count, 0L);
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// And ink lands outside the box, which is the only assertion here that somebody
|
|
395
|
+
// looking at the screen would make.
|
|
396
|
+
//
|
|
397
|
+
// The whole list in the tree dump, which is what makes the wiring testable: the
|
|
398
|
+
// prop is read in AppKitMountingManager, and every test above sets the shadows on
|
|
399
|
+
// the view by hand. Spelled as GTK spells them, so the cross-host diff compares.
|
|
400
|
+
TEST(box_shadows_are_reported_in_the_tree) {
|
|
401
|
+
@autoreleasepool {
|
|
402
|
+
RnAppKitBoxShadow shadows[2] = {outsetShadow(2, 4, 8, 1), outsetShadow(0, 1, 0, 0)};
|
|
403
|
+
shadows[0].color[3] = 0.25;
|
|
404
|
+
shadows[1].color[0] = 1;
|
|
405
|
+
shadows[1].color[1] = 1;
|
|
406
|
+
shadows[1].color[2] = 1;
|
|
407
|
+
shadows[1].inset = true;
|
|
408
|
+
RnAppKitView *view = shadowed(shadows, 2, CGRectMake(50, 50, 100, 60));
|
|
409
|
+
|
|
410
|
+
const std::string described = [view describeTree].UTF8String;
|
|
411
|
+
EXPECT(described.find("shadow=(2,4,8,1,#00000040)") != std::string::npos);
|
|
412
|
+
EXPECT(described.find("shadow=(inset 0,1,0,0,#ffffffff)") != std::string::npos);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
// `backgroundImage` as a linear gradient.
|
|
417
|
+
//
|
|
418
|
+
// Pixels here, unlike the shadows: a gradient is drawn in `drawRect:` with Core
|
|
419
|
+
// Graphics, so the same bitmap the border tests use shows it. What is asserted is
|
|
420
|
+
// the ramp itself -- a colour at one end, the other colour at the other, and
|
|
421
|
+
// something in between in the middle -- because a gradient drawn along the wrong
|
|
422
|
+
// line, or with its stops reversed, is still a gradient.
|
|
423
|
+
//
|
|
424
|
+
// The arithmetic is not asserted here. The angle and the stop fixup are shared
|
|
425
|
+
// with the GTK host in core/Gradients.h and have their own tests; this is the
|
|
426
|
+
// drawing.
|
|
427
|
+
namespace {
|
|
428
|
+
|
|
429
|
+
RnAppKitGradientStop gradientStop(CGFloat offset, CGFloat red, CGFloat green, CGFloat blue) {
|
|
430
|
+
RnAppKitGradientStop stop{};
|
|
431
|
+
stop.offset = offset;
|
|
432
|
+
stop.color[0] = red;
|
|
433
|
+
stop.color[1] = green;
|
|
434
|
+
stop.color[2] = blue;
|
|
435
|
+
stop.color[3] = 1;
|
|
436
|
+
return stop;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// The red, green and blue of one pixel of the drawn view.
|
|
440
|
+
struct Pixel {
|
|
441
|
+
int red;
|
|
442
|
+
int green;
|
|
443
|
+
int blue;
|
|
444
|
+
};
|
|
445
|
+
|
|
446
|
+
Pixel pixelAt(RnAppKitView *view, CGSize size, int x, int y) {
|
|
447
|
+
CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
|
448
|
+
CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width, (size_t)size.height,
|
|
449
|
+
8, 0, space, kCGImageAlphaPremultipliedLast);
|
|
450
|
+
CGColorSpaceRelease(space);
|
|
451
|
+
CGContextSetRGBFillColor(context, 1, 1, 1, 1);
|
|
452
|
+
CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
|
|
453
|
+
CGContextTranslateCTM(context, 0, size.height);
|
|
454
|
+
CGContextScaleCTM(context, 1, -1);
|
|
455
|
+
|
|
456
|
+
NSGraphicsContext *previous = NSGraphicsContext.currentContext;
|
|
457
|
+
NSGraphicsContext.currentContext = [NSGraphicsContext graphicsContextWithCGContext:context
|
|
458
|
+
flipped:YES];
|
|
459
|
+
[view drawRect:NSMakeRect(0, 0, size.width, size.height)];
|
|
460
|
+
NSGraphicsContext.currentContext = previous;
|
|
461
|
+
|
|
462
|
+
auto *bytes = static_cast<unsigned char *>(CGBitmapContextGetData(context));
|
|
463
|
+
const size_t stride = CGBitmapContextGetBytesPerRow(context);
|
|
464
|
+
const size_t at = (size_t)y * stride + (size_t)x * 4;
|
|
465
|
+
const Pixel pixel{bytes[at], bytes[at + 1], bytes[at + 2]};
|
|
466
|
+
CGContextRelease(context);
|
|
467
|
+
return pixel;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// A `backgroundImage` entry of each kind, since the struct the view takes
|
|
471
|
+
// carries both and only some of its fields mean anything to either.
|
|
472
|
+
// The rectangle an image fills, which the mounting manager resolves from
|
|
473
|
+
// `backgroundSize` and friends and these tests do not otherwise care about: the
|
|
474
|
+
// view's own box, drawn once, which is what the defaults come to for a view with
|
|
475
|
+
// no border. The tiling is asserted in core's tests and in the tests below it.
|
|
476
|
+
CGRect wholeBox(CGSize size) { return CGRectMake(0, 0, size.width, size.height); }
|
|
477
|
+
|
|
478
|
+
RnAppKitGradient linearGradient(CGPoint start,
|
|
479
|
+
CGPoint end,
|
|
480
|
+
const RnAppKitGradientStop *stops,
|
|
481
|
+
NSInteger count,
|
|
482
|
+
CGRect area) {
|
|
483
|
+
RnAppKitGradient gradient{};
|
|
484
|
+
gradient.kind = RnAppKitGradientKindLinear;
|
|
485
|
+
gradient.start = start;
|
|
486
|
+
gradient.end = end;
|
|
487
|
+
gradient.area = area;
|
|
488
|
+
gradient.tile = area;
|
|
489
|
+
gradient.repeats = false;
|
|
490
|
+
gradient.stops = stops;
|
|
491
|
+
gradient.stopCount = count;
|
|
492
|
+
return gradient;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
RnAppKitGradient radialGradient(CGPoint center,
|
|
496
|
+
CGFloat radiusX,
|
|
497
|
+
CGFloat radiusY,
|
|
498
|
+
const RnAppKitGradientStop *stops,
|
|
499
|
+
NSInteger count,
|
|
500
|
+
CGRect area) {
|
|
501
|
+
RnAppKitGradient gradient{};
|
|
502
|
+
gradient.kind = RnAppKitGradientKindRadial;
|
|
503
|
+
gradient.center = center;
|
|
504
|
+
gradient.radiusX = radiusX;
|
|
505
|
+
gradient.radiusY = radiusY;
|
|
506
|
+
gradient.area = area;
|
|
507
|
+
gradient.tile = area;
|
|
508
|
+
gradient.repeats = false;
|
|
509
|
+
gradient.stops = stops;
|
|
510
|
+
gradient.stopCount = count;
|
|
511
|
+
return gradient;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
RnAppKitView *gradientView(const RnAppKitGradient *gradients, NSInteger count, CGSize size) {
|
|
515
|
+
RnAppKitView *view = [RnAppKitView viewWithTag:1];
|
|
516
|
+
[view setRnFrameX:0 y:0 width:size.width height:size.height];
|
|
517
|
+
[view setRnGradients:gradients count:count];
|
|
518
|
+
return view;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
} // namespace
|
|
522
|
+
|
|
523
|
+
TEST(gradient_a_left_to_right_ramp_is_drawn_across_the_box) {
|
|
524
|
+
@autoreleasepool {
|
|
525
|
+
const CGSize size = CGSizeMake(100, 40);
|
|
526
|
+
const RnAppKitGradientStop stops[2] = {gradientStop(0, 1, 0, 0), gradientStop(1, 0, 0, 1)};
|
|
527
|
+
const RnAppKitGradient gradient = linearGradient(CGPointMake(0, 0), CGPointMake(100, 0), stops, 2, wholeBox(size));
|
|
528
|
+
RnAppKitView *view = gradientView(&gradient, 1, size);
|
|
529
|
+
|
|
530
|
+
EXPECT_EQ((long)view.rnGradientCount, 1L);
|
|
531
|
+
const Pixel left = pixelAt(view, size, 2, 20);
|
|
532
|
+
const Pixel middle = pixelAt(view, size, 50, 20);
|
|
533
|
+
const Pixel right = pixelAt(view, size, 97, 20);
|
|
534
|
+
|
|
535
|
+
// Red at the left, blue at the right, and mixed in between -- which is the
|
|
536
|
+
// assertion that fails if the line runs the other way.
|
|
537
|
+
EXPECT(left.red > 200 && left.blue < 60);
|
|
538
|
+
EXPECT(right.blue > 200 && right.red < 60);
|
|
539
|
+
EXPECT(middle.red > 60 && middle.red < 200);
|
|
540
|
+
EXPECT(middle.blue > 60 && middle.blue < 200);
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
// The colours extend past the ends of the gradient line, which CSS requires and
|
|
545
|
+
// which matters because the line is usually shorter than the box: without it a
|
|
546
|
+
// diagonal gradient leaves two corners unpainted.
|
|
547
|
+
TEST(gradient_extends_beyond_the_ends_of_its_line) {
|
|
548
|
+
@autoreleasepool {
|
|
549
|
+
const CGSize size = CGSizeMake(100, 40);
|
|
550
|
+
// A line covering only the middle fifth of the box.
|
|
551
|
+
const RnAppKitGradientStop stops[2] = {gradientStop(0, 1, 0, 0), gradientStop(1, 0, 0, 1)};
|
|
552
|
+
const RnAppKitGradient gradient = linearGradient(CGPointMake(40, 0), CGPointMake(60, 0), stops, 2, wholeBox(size));
|
|
553
|
+
RnAppKitView *view = gradientView(&gradient, 1, size);
|
|
554
|
+
|
|
555
|
+
const Pixel left = pixelAt(view, size, 2, 20);
|
|
556
|
+
const Pixel right = pixelAt(view, size, 97, 20);
|
|
557
|
+
// Still the end colours rather than the white the view was drawn onto.
|
|
558
|
+
EXPECT(left.red > 200 && left.blue < 60);
|
|
559
|
+
EXPECT(right.blue > 200 && right.red < 60);
|
|
560
|
+
}
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Several gradients, back to front: the first in the list is on top, so a second
|
|
564
|
+
// opaque one underneath it must not win.
|
|
565
|
+
TEST(gradient_the_first_in_the_list_is_on_top) {
|
|
566
|
+
@autoreleasepool {
|
|
567
|
+
const CGSize size = CGSizeMake(100, 40);
|
|
568
|
+
const RnAppKitGradientStop top[2] = {gradientStop(0, 1, 0, 0), gradientStop(1, 1, 0, 0)};
|
|
569
|
+
const RnAppKitGradientStop bottom[2] = {gradientStop(0, 0, 1, 0), gradientStop(1, 0, 1, 0)};
|
|
570
|
+
const RnAppKitGradient gradients[2] = {linearGradient(CGPointMake(0, 0), CGPointMake(100, 0), top, 2, wholeBox(size)), linearGradient(CGPointMake(0, 0), CGPointMake(100, 0), bottom, 2, wholeBox(size))};
|
|
571
|
+
RnAppKitView *view = gradientView(gradients, 2, size);
|
|
572
|
+
|
|
573
|
+
EXPECT_EQ((long)view.rnGradientCount, 2L);
|
|
574
|
+
const Pixel middle = pixelAt(view, size, 50, 20);
|
|
575
|
+
EXPECT(middle.red > 200);
|
|
576
|
+
EXPECT(middle.green < 60);
|
|
577
|
+
}
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
TEST(gradient_can_be_taken_away_again) {
|
|
581
|
+
@autoreleasepool {
|
|
582
|
+
const CGSize size = CGSizeMake(100, 40);
|
|
583
|
+
const RnAppKitGradientStop stops[2] = {gradientStop(0, 1, 0, 0), gradientStop(1, 0, 0, 1)};
|
|
584
|
+
const RnAppKitGradient gradient = linearGradient(CGPointMake(0, 0), CGPointMake(100, 0), stops, 2, wholeBox(size));
|
|
585
|
+
RnAppKitView *view = gradientView(&gradient, 1, size);
|
|
586
|
+
|
|
587
|
+
[view setRnGradients:nullptr count:0];
|
|
588
|
+
EXPECT_EQ((long)view.rnGradientCount, 0L);
|
|
589
|
+
// White, which is what the bitmap was filled with: nothing was drawn.
|
|
590
|
+
const Pixel middle = pixelAt(view, size, 50, 20);
|
|
591
|
+
EXPECT(middle.red > 250 && middle.green > 250 && middle.blue > 250);
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
// Rounded corners clip it, as they clip the background colour.
|
|
596
|
+
TEST(gradient_is_clipped_to_the_rounded_box) {
|
|
597
|
+
@autoreleasepool {
|
|
598
|
+
const CGSize size = CGSizeMake(100, 40);
|
|
599
|
+
const RnAppKitGradientStop stops[2] = {gradientStop(0, 1, 0, 0), gradientStop(1, 0, 0, 1)};
|
|
600
|
+
const RnAppKitGradient gradient = linearGradient(CGPointMake(0, 0), CGPointMake(100, 0), stops, 2, wholeBox(size));
|
|
601
|
+
RnAppKitView *view = gradientView(&gradient, 1, size);
|
|
602
|
+
[view setRnCornerRadius:20];
|
|
603
|
+
|
|
604
|
+
// The top-left corner is outside a 20pt radius, so it keeps the white it was
|
|
605
|
+
// drawn onto; the middle of the left edge is inside and is painted.
|
|
606
|
+
const Pixel corner = pixelAt(view, size, 1, 1);
|
|
607
|
+
const Pixel edge = pixelAt(view, size, 2, 20);
|
|
608
|
+
EXPECT(corner.red > 250 && corner.blue > 250);
|
|
609
|
+
EXPECT(edge.red > 200 && edge.blue < 60);
|
|
610
|
+
}
|
|
611
|
+
}
|
|
612
|
+
|
|
613
|
+
// A radial gradient, in pixels. The two radii are the part worth asserting:
|
|
614
|
+
// Core Graphics draws radial gradients between two circles, so an ellipse is a
|
|
615
|
+
// scaled coordinate system here, and a scale on the wrong axis still draws a
|
|
616
|
+
// plausible radial gradient.
|
|
617
|
+
TEST(gradient_a_radial_ramp_runs_from_its_centre) {
|
|
618
|
+
@autoreleasepool {
|
|
619
|
+
const CGSize size = CGSizeMake(100, 100);
|
|
620
|
+
const RnAppKitGradientStop stops[2] = {gradientStop(0, 1, 0, 0), gradientStop(1, 0, 0, 1)};
|
|
621
|
+
const RnAppKitGradient gradient = radialGradient(CGPointMake(50, 50), 25, 25, stops, 2, wholeBox(size));
|
|
622
|
+
RnAppKitView *view = gradientView(&gradient, 1, size);
|
|
623
|
+
|
|
624
|
+
EXPECT_EQ((long)view.rnGradientCount, 1L);
|
|
625
|
+
const Pixel centre = pixelAt(view, size, 50, 50);
|
|
626
|
+
const Pixel outside = pixelAt(view, size, 50, 10);
|
|
627
|
+
const Pixel between = pixelAt(view, size, 50, 37);
|
|
628
|
+
|
|
629
|
+
EXPECT(centre.red > 200 && centre.blue < 60);
|
|
630
|
+
// Past the ending shape, where CSS keeps painting the last stop rather than
|
|
631
|
+
// leaving the rest of the box unpainted.
|
|
632
|
+
EXPECT(outside.blue > 200 && outside.red < 60);
|
|
633
|
+
EXPECT(between.red > 40 && between.red < 215);
|
|
634
|
+
EXPECT(between.blue > 40 && between.blue < 215);
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Each radius on its own axis. One point is inside the ellipse and one is
|
|
639
|
+
// outside it at the same distance from the centre, so a circle of either radius
|
|
640
|
+
// gets one of the two wrong.
|
|
641
|
+
TEST(gradient_a_radial_ramp_uses_each_radius_on_its_own_axis) {
|
|
642
|
+
@autoreleasepool {
|
|
643
|
+
const CGSize size = CGSizeMake(100, 100);
|
|
644
|
+
const RnAppKitGradientStop stops[2] = {gradientStop(0, 1, 0, 0), gradientStop(1, 0, 0, 1)};
|
|
645
|
+
const RnAppKitGradient gradient = radialGradient(CGPointMake(50, 50), 40, 20, stops, 2, wholeBox(size));
|
|
646
|
+
RnAppKitView *view = gradientView(&gradient, 1, size);
|
|
647
|
+
|
|
648
|
+
// 35 to the left, of a 40 radius: inside, so short of the last stop.
|
|
649
|
+
const Pixel sideways = pixelAt(view, size, 15, 50);
|
|
650
|
+
EXPECT(sideways.blue < 250);
|
|
651
|
+
EXPECT(sideways.red > 5);
|
|
652
|
+
// 35 above, of a 20 radius: outside, so the last stop.
|
|
653
|
+
const Pixel above = pixelAt(view, size, 50, 15);
|
|
654
|
+
EXPECT(above.blue > 200 && above.red < 60);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Off centre, which is the other thing a picture can say and a stored number
|
|
659
|
+
// cannot: a centre ignored and defaulted to the middle of the box paints the
|
|
660
|
+
// middle red rather than blue.
|
|
661
|
+
TEST(gradient_a_radial_ramp_is_centred_where_it_was_told) {
|
|
662
|
+
@autoreleasepool {
|
|
663
|
+
const CGSize size = CGSizeMake(100, 100);
|
|
664
|
+
const RnAppKitGradientStop stops[2] = {gradientStop(0, 1, 0, 0), gradientStop(1, 0, 0, 1)};
|
|
665
|
+
const RnAppKitGradient gradient = radialGradient(CGPointMake(20, 20), 15, 15, stops, 2, wholeBox(size));
|
|
666
|
+
RnAppKitView *view = gradientView(&gradient, 1, size);
|
|
667
|
+
|
|
668
|
+
const Pixel centre = pixelAt(view, size, 20, 20);
|
|
669
|
+
const Pixel middle = pixelAt(view, size, 50, 50);
|
|
670
|
+
EXPECT(centre.red > 200 && centre.blue < 60);
|
|
671
|
+
EXPECT(middle.blue > 200 && middle.red < 60);
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
// Both kinds in one list, in the order the stylesheet wrote them: the first is
|
|
676
|
+
// on top, so the radial one here covers the linear one underneath.
|
|
677
|
+
TEST(gradient_a_radial_and_a_linear_one_keep_their_order) {
|
|
678
|
+
@autoreleasepool {
|
|
679
|
+
const CGSize size = CGSizeMake(100, 100);
|
|
680
|
+
const RnAppKitGradientStop red[2] = {gradientStop(0, 1, 0, 0), gradientStop(1, 1, 0, 0)};
|
|
681
|
+
const RnAppKitGradientStop green[2] = {gradientStop(0, 0, 1, 0), gradientStop(1, 0, 1, 0)};
|
|
682
|
+
const RnAppKitGradient gradients[2] = {
|
|
683
|
+
radialGradient(CGPointMake(50, 50), 40, 40, red, 2, wholeBox(size)),
|
|
684
|
+
linearGradient(CGPointMake(0, 0), CGPointMake(100, 0), green, 2, wholeBox(size)),
|
|
685
|
+
};
|
|
686
|
+
RnAppKitView *view = gradientView(gradients, 2, size);
|
|
687
|
+
|
|
688
|
+
EXPECT_EQ((long)view.rnGradientCount, 2L);
|
|
689
|
+
// Inside the radial one, which is on top: red.
|
|
690
|
+
const Pixel inside = pixelAt(view, size, 50, 50);
|
|
691
|
+
EXPECT(inside.red > 200 && inside.green < 60);
|
|
692
|
+
// The radial one's last stop is red too and it extends past its shape, so
|
|
693
|
+
// the whole box is red: what this asserts is that the linear one did not
|
|
694
|
+
// win, which it would if the list were drawn front to back.
|
|
695
|
+
const Pixel corner = pixelAt(view, size, 3, 3);
|
|
696
|
+
EXPECT(corner.red > 200 && corner.green < 60);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
// `backgroundSize`, `backgroundPosition` and `backgroundRepeat`, in pixels.
|
|
701
|
+
//
|
|
702
|
+
// The arithmetic is core's and is tested there. What only a picture can say is
|
|
703
|
+
// whether this layer uses it -- and on this host the tiling is a loop rather
|
|
704
|
+
// than a node, so the loop's bounds, its step and its per-tile clip are all
|
|
705
|
+
// things a resolved-numbers test cannot see.
|
|
706
|
+
//
|
|
707
|
+
// Flat gradients throughout, two stops of one colour, so a painted pixel is one
|
|
708
|
+
// colour and a gap is the white the view was drawn onto.
|
|
709
|
+
namespace {
|
|
710
|
+
|
|
711
|
+
RnAppKitGradient flatImage(CGRect area, CGRect tile, bool repeats) {
|
|
712
|
+
static const RnAppKitGradientStop red[2] = {gradientStop(0, 1, 0, 0), gradientStop(1, 1, 0, 0)};
|
|
713
|
+
RnAppKitGradient gradient{};
|
|
714
|
+
gradient.kind = RnAppKitGradientKindLinear;
|
|
715
|
+
gradient.start = CGPointMake(area.origin.x, area.origin.y);
|
|
716
|
+
gradient.end = CGPointMake(CGRectGetMaxX(area), area.origin.y);
|
|
717
|
+
gradient.area = area;
|
|
718
|
+
gradient.tile = tile;
|
|
719
|
+
gradient.repeats = repeats;
|
|
720
|
+
gradient.stops = red;
|
|
721
|
+
gradient.stopCount = 2;
|
|
722
|
+
return gradient;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
} // namespace
|
|
726
|
+
|
|
727
|
+
// A size smaller than the view, drawn once. Before these props were read a
|
|
728
|
+
// gradient filled the view whatever the stylesheet asked for, and the clip per
|
|
729
|
+
// tile is what keeps it in its rectangle: the gradient itself is drawn with
|
|
730
|
+
// `kCGGradientDrawsAfterEndLocation`, which fills whatever it is clipped to.
|
|
731
|
+
TEST(gradient_an_image_fills_its_own_rectangle) {
|
|
732
|
+
@autoreleasepool {
|
|
733
|
+
const CGSize size = CGSizeMake(100, 100);
|
|
734
|
+
const CGRect area = CGRectMake(0, 0, 50, 50);
|
|
735
|
+
const RnAppKitGradient gradient = flatImage(area, area, false);
|
|
736
|
+
RnAppKitView *view = gradientView(&gradient, 1, size);
|
|
737
|
+
|
|
738
|
+
const Pixel inside = pixelAt(view, size, 25, 25);
|
|
739
|
+
EXPECT(inside.red > 200 && inside.green < 60);
|
|
740
|
+
// Outside it, the white it was drawn onto rather than the gradient.
|
|
741
|
+
const Pixel outside = pixelAt(view, size, 75, 75);
|
|
742
|
+
EXPECT(outside.green > 200);
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
TEST(gradient_an_image_is_drawn_where_it_was_positioned) {
|
|
747
|
+
@autoreleasepool {
|
|
748
|
+
const CGSize size = CGSizeMake(100, 100);
|
|
749
|
+
const CGRect area = CGRectMake(40, 60, 30, 30);
|
|
750
|
+
const RnAppKitGradient gradient = flatImage(area, area, false);
|
|
751
|
+
RnAppKitView *view = gradientView(&gradient, 1, size);
|
|
752
|
+
|
|
753
|
+
const Pixel inside = pixelAt(view, size, 55, 75);
|
|
754
|
+
EXPECT(inside.red > 200 && inside.green < 60);
|
|
755
|
+
// The corner the default would have painted.
|
|
756
|
+
const Pixel corner = pixelAt(view, size, 5, 5);
|
|
757
|
+
EXPECT(corner.green > 200);
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
// `repeat`: one 25pt image over a 100pt box is four tiles each way. A loop that
|
|
762
|
+
// ran once leaves three quarters of each axis white, which is what the far
|
|
763
|
+
// corners check.
|
|
764
|
+
TEST(gradient_an_image_repeats_across_the_painting_area) {
|
|
765
|
+
@autoreleasepool {
|
|
766
|
+
const CGSize size = CGSizeMake(100, 100);
|
|
767
|
+
const CGRect area = CGRectMake(0, 0, 25, 25);
|
|
768
|
+
const RnAppKitGradient gradient = flatImage(area, area, true);
|
|
769
|
+
RnAppKitView *view = gradientView(&gradient, 1, size);
|
|
770
|
+
|
|
771
|
+
for (const CGPoint point :
|
|
772
|
+
{CGPointMake(12, 12), CGPointMake(87, 12), CGPointMake(12, 87), CGPointMake(87, 87)}) {
|
|
773
|
+
const Pixel pixel = pixelAt(view, size, (int)point.x, (int)point.y);
|
|
774
|
+
EXPECT(pixel.red > 200 && pixel.green < 60);
|
|
775
|
+
}
|
|
776
|
+
}
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
// `space`, where the tile is wider than the image and the gaps are real: this is
|
|
780
|
+
// what tells the per-tile clip from a gradient left to fill its tile.
|
|
781
|
+
TEST(gradient_a_spaced_image_leaves_the_gaps_empty) {
|
|
782
|
+
@autoreleasepool {
|
|
783
|
+
const CGSize size = CGSizeMake(100, 100);
|
|
784
|
+
const RnAppKitGradient gradient =
|
|
785
|
+
flatImage(CGRectMake(0, 0, 30, 100), CGRectMake(0, 0, 50, 100), true);
|
|
786
|
+
RnAppKitView *view = gradientView(&gradient, 1, size);
|
|
787
|
+
|
|
788
|
+
const Pixel first = pixelAt(view, size, 15, 50);
|
|
789
|
+
const Pixel gap = pixelAt(view, size, 40, 50);
|
|
790
|
+
const Pixel second = pixelAt(view, size, 65, 50);
|
|
791
|
+
EXPECT(first.red > 200 && first.green < 60);
|
|
792
|
+
EXPECT(gap.green > 200);
|
|
793
|
+
EXPECT(second.red > 200 && second.green < 60);
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
|
|
797
|
+
// One axis only, which arrives as a tile spanning the painting area downwards:
|
|
798
|
+
// a band rather than a grid.
|
|
799
|
+
TEST(gradient_an_image_can_repeat_in_one_axis_only) {
|
|
800
|
+
@autoreleasepool {
|
|
801
|
+
const CGSize size = CGSizeMake(100, 100);
|
|
802
|
+
const RnAppKitGradient gradient =
|
|
803
|
+
flatImage(CGRectMake(0, 40, 20, 20), CGRectMake(0, 0, 20, 100), true);
|
|
804
|
+
RnAppKitView *view = gradientView(&gradient, 1, size);
|
|
805
|
+
|
|
806
|
+
const Pixel band = pixelAt(view, size, 90, 50);
|
|
807
|
+
EXPECT(band.red > 200 && band.green < 60);
|
|
808
|
+
// Nothing above or below it, which says the other axis did not repeat too.
|
|
809
|
+
const Pixel above = pixelAt(view, size, 10, 10);
|
|
810
|
+
EXPECT(above.green > 200);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
// And the gradients are in the tree dump, spelled as GTK spells them, which is
|
|
815
|
+
// what makes the wiring testable: the angle and the stops are resolved in
|
|
816
|
+
// AppKitMountingManager and the tests above hand the view the answer.
|
|
817
|
+
TEST(gradients_are_reported_in_the_tree) {
|
|
818
|
+
@autoreleasepool {
|
|
819
|
+
const CGSize size = CGSizeMake(100, 60);
|
|
820
|
+
const RnAppKitGradientStop stops[3] = {
|
|
821
|
+
gradientStop(0, 1, 0, 0), gradientStop(0.5, 0, 1, 0), gradientStop(1, 0, 0, 1)};
|
|
822
|
+
const RnAppKitGradient gradient = linearGradient(CGPointMake(0, 60), CGPointMake(0, 0), stops, 3, wholeBox(size));
|
|
823
|
+
RnAppKitView *view = gradientView(&gradient, 1, size);
|
|
824
|
+
|
|
825
|
+
const std::string described = [view describeTree].UTF8String;
|
|
826
|
+
// The line carries where the image goes as well, which is what the three
|
|
827
|
+
// background props come to: the view's own box, drawn once.
|
|
828
|
+
EXPECT(described.find("gradient=((0,60)-(0,0),3 stops,at=(0,0 100x60))") !=
|
|
829
|
+
std::string::npos);
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// `outlineWidth`, `outlineColor`, `outlineOffset` and `outlineStyle`.
|
|
834
|
+
//
|
|
835
|
+
// CSS's outline is not a border: it is drawn outside the box, it takes no layout
|
|
836
|
+
// space, and it is one ring rather than four edges. So the things worth asserting
|
|
837
|
+
// are the geometry of the ring -- which is the box grown by the offset plus half
|
|
838
|
+
// the stroke width, since a stroke straddles its path -- and where the layer that
|
|
839
|
+
// carries it lives, for the same reason the box shadows care: a view that clips
|
|
840
|
+
// itself must not clip its own outline away.
|
|
841
|
+
//
|
|
842
|
+
// No pixels, for the reason given above the box shadow tests: a CAShapeLayer's
|
|
843
|
+
// stroke is drawn by Core Animation, and `renderInContext:` does not draw it.
|
|
844
|
+
namespace {
|
|
845
|
+
|
|
846
|
+
RnAppKitView *outlined(CGFloat width, CGFloat offset, RnAppKitBorderStyle style, CGRect frame) {
|
|
847
|
+
RnAppKitView *parent = [RnAppKitView viewWithTag:1];
|
|
848
|
+
[parent setRnFrameX:0 y:0 width:200 height:200];
|
|
849
|
+
RnAppKitView *view = [RnAppKitView viewWithTag:2];
|
|
850
|
+
[parent insertRnChild:view atIndex:0];
|
|
851
|
+
[view setRnFrameX:frame.origin.x y:frame.origin.y
|
|
852
|
+
width:frame.size.width height:frame.size.height];
|
|
853
|
+
const CGFloat black[4] = {0, 0, 0, 1};
|
|
854
|
+
[view setRnOutlineWidth:width offset:offset color:black style:style];
|
|
855
|
+
return view;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
} // namespace
|
|
859
|
+
|
|
860
|
+
TEST(outline_becomes_a_stroked_layer_outside_the_box) {
|
|
861
|
+
@autoreleasepool {
|
|
862
|
+
RnAppKitView *view = outlined(4, 0, RnAppKitBorderStyleSolid, CGRectMake(50, 50, 100, 60));
|
|
863
|
+
CAShapeLayer *layer = view.rnOutlineLayer;
|
|
864
|
+
EXPECT(layer != nil);
|
|
865
|
+
|
|
866
|
+
// Stroked, not filled: a filled shape would cover the view.
|
|
867
|
+
EXPECT(layer.fillColor == nil);
|
|
868
|
+
EXPECT(layer.strokeColor != nil);
|
|
869
|
+
EXPECT_EQ((int)layer.lineWidth, 4);
|
|
870
|
+
|
|
871
|
+
// Half a width outside the box, so the inner edge of the ring touches it.
|
|
872
|
+
const CGRect box = CGPathGetBoundingBox(layer.path);
|
|
873
|
+
EXPECT_EQ((int)box.origin.x, -2);
|
|
874
|
+
EXPECT_EQ((int)box.origin.y, -2);
|
|
875
|
+
EXPECT_EQ((int)box.size.width, 104);
|
|
876
|
+
EXPECT_EQ((int)box.size.height, 64);
|
|
877
|
+
|
|
878
|
+
// Solid means no dashes, which is what distinguishes it from the two below.
|
|
879
|
+
EXPECT(layer.lineDashPattern == nil);
|
|
880
|
+
}
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// The offset, which is the whole point of an outline over a border: it leaves a
|
|
884
|
+
// gap. A width read as an offset, or an offset ignored, both still draw a ring,
|
|
885
|
+
// so the bounding box is what catches it.
|
|
886
|
+
TEST(outline_an_offset_pushes_the_ring_further_out) {
|
|
887
|
+
@autoreleasepool {
|
|
888
|
+
RnAppKitView *view = outlined(4, 6, RnAppKitBorderStyleSolid, CGRectMake(50, 50, 100, 60));
|
|
889
|
+
const CGRect box = CGPathGetBoundingBox(view.rnOutlineLayer.path);
|
|
890
|
+
EXPECT_EQ((int)box.origin.x, -8); // -(offset + width / 2)
|
|
891
|
+
EXPECT_EQ((int)box.origin.y, -8);
|
|
892
|
+
EXPECT_EQ((int)box.size.width, 116);
|
|
893
|
+
EXPECT_EQ((int)box.size.height, 76);
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
TEST(outline_dotted_and_dashed_become_dash_patterns) {
|
|
898
|
+
@autoreleasepool {
|
|
899
|
+
RnAppKitView *dotted = outlined(4, 0, RnAppKitBorderStyleDotted, CGRectMake(0, 0, 100, 60));
|
|
900
|
+
EXPECT_EQ((long)dotted.rnOutlineLayer.lineDashPattern.count, 2L);
|
|
901
|
+
// A zero-length segment with a round cap is a dot, and the gap is what makes
|
|
902
|
+
// it read as dotted rather than as a solid line of dots.
|
|
903
|
+
EXPECT_EQ(dotted.rnOutlineLayer.lineDashPattern.firstObject.intValue, 0);
|
|
904
|
+
EXPECT([dotted.rnOutlineLayer.lineCap isEqualToString:kCALineCapRound]);
|
|
905
|
+
|
|
906
|
+
RnAppKitView *dashed = outlined(4, 0, RnAppKitBorderStyleDashed, CGRectMake(0, 0, 100, 60));
|
|
907
|
+
EXPECT_EQ((long)dashed.rnOutlineLayer.lineDashPattern.count, 2L);
|
|
908
|
+
EXPECT(dashed.rnOutlineLayer.lineDashPattern.firstObject.intValue > 0);
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
|
|
912
|
+
// No width is no outline, and neither is a transparent colour. The default props
|
|
913
|
+
// carry a zero width, so every view in every app goes through this path.
|
|
914
|
+
TEST(outline_nothing_is_drawn_without_a_width_or_a_colour) {
|
|
915
|
+
@autoreleasepool {
|
|
916
|
+
RnAppKitView *none = outlined(0, 0, RnAppKitBorderStyleSolid, CGRectMake(0, 0, 100, 60));
|
|
917
|
+
EXPECT(none.rnOutlineLayer == nil);
|
|
918
|
+
|
|
919
|
+
RnAppKitView *view = outlined(4, 0, RnAppKitBorderStyleSolid, CGRectMake(0, 0, 100, 60));
|
|
920
|
+
EXPECT(view.rnOutlineLayer != nil);
|
|
921
|
+
const CGFloat clear[4] = {0, 0, 0, 0};
|
|
922
|
+
[view setRnOutlineWidth:4 offset:0 color:clear style:RnAppKitBorderStyleSolid];
|
|
923
|
+
EXPECT(view.rnOutlineLayer == nil);
|
|
924
|
+
}
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
// Where the layer lives, which decides whether `overflow: 'hidden'` eats the
|
|
928
|
+
// outline. Same rule as the box shadows, and the same failure if it is wrong.
|
|
929
|
+
TEST(outline_a_clipping_view_puts_its_outline_in_the_parent) {
|
|
930
|
+
@autoreleasepool {
|
|
931
|
+
RnAppKitView *view = outlined(4, 0, RnAppKitBorderStyleSolid, CGRectMake(50, 50, 100, 60));
|
|
932
|
+
EXPECT(view.rnOutlineLayer.superlayer == view.layer);
|
|
933
|
+
|
|
934
|
+
[view setRnClipsChildren:YES];
|
|
935
|
+
CAShapeLayer *layer = view.rnOutlineLayer;
|
|
936
|
+
EXPECT(layer.superlayer == view.superview.layer);
|
|
937
|
+
// In the parent's coordinates, so the ring is still around the view.
|
|
938
|
+
EXPECT_EQ((int)layer.frame.origin.x, 50);
|
|
939
|
+
EXPECT_EQ((int)layer.frame.origin.y, 50);
|
|
940
|
+
|
|
941
|
+
[view setRnClipsChildren:NO];
|
|
942
|
+
EXPECT(view.rnOutlineLayer.superlayer == view.layer);
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// The ring is concentric with the box, so a rounded box gets a rounded ring with
|
|
947
|
+
// the radius grown by the same amount the ring moved out. A square ring around a
|
|
948
|
+
// rounded box is the wrong answer that looks almost right, so the corner itself
|
|
949
|
+
// is what gets asserted: inside a square path, outside a rounded one.
|
|
950
|
+
TEST(outline_follows_the_corner_radius) {
|
|
951
|
+
@autoreleasepool {
|
|
952
|
+
RnAppKitView *square = outlined(4, 0, RnAppKitBorderStyleSolid, CGRectMake(50, 50, 100, 60));
|
|
953
|
+
const CGRect box = CGPathGetBoundingBox(square.rnOutlineLayer.path);
|
|
954
|
+
const CGPoint corner = CGPointMake(box.origin.x + 1, box.origin.y + 1);
|
|
955
|
+
EXPECT(CGPathContainsPoint(square.rnOutlineLayer.path, NULL, corner, false));
|
|
956
|
+
|
|
957
|
+
RnAppKitView *rounded = outlined(4, 0, RnAppKitBorderStyleSolid, CGRectMake(50, 50, 100, 60));
|
|
958
|
+
[rounded setRnCornerRadius:20];
|
|
959
|
+
EXPECT(!CGPathContainsPoint(rounded.rnOutlineLayer.path, NULL, corner, false));
|
|
960
|
+
// And still only as big as the square one, because the radius grew rather
|
|
961
|
+
// than the ring.
|
|
962
|
+
const CGRect roundedBox = CGPathGetBoundingBox(rounded.rnOutlineLayer.path);
|
|
963
|
+
EXPECT_EQ((int)roundedBox.size.width, (int)box.size.width);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
|
|
967
|
+
// And in the tree dump, spelled as GTK spells it, which is what lets one e2e
|
|
968
|
+
// scenario check the wiring on both hosts.
|
|
969
|
+
TEST(outline_is_reported_in_the_tree) {
|
|
970
|
+
@autoreleasepool {
|
|
971
|
+
RnAppKitView *view = outlined(3, 2, RnAppKitBorderStyleDashed, CGRectMake(0, 0, 100, 60));
|
|
972
|
+
const std::string described = [view describeTree].UTF8String;
|
|
973
|
+
EXPECT(described.find("outline=(3,2,#000000ff,dashed)") != std::string::npos);
|
|
974
|
+
}
|
|
975
|
+
}
|