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
|
@@ -73,8 +73,88 @@ bool inkAt(NSView *view, CGSize size, int atX, int atY) {
|
|
|
73
73
|
return marked;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
+
// The red channel at a point, drawn over mid-grey rather than white.
|
|
77
|
+
//
|
|
78
|
+
// Grey because this is what tells the thumb's *colour* apart rather than its
|
|
79
|
+
// presence: a black thumb at core's alpha darkens grey and a white one lightens
|
|
80
|
+
// it, where over white only one of the two shows up at all.
|
|
81
|
+
int greyAt(NSView *view, CGSize size, int atX, int atY) {
|
|
82
|
+
CGColorSpaceRef space = CGColorSpaceCreateWithName(kCGColorSpaceSRGB);
|
|
83
|
+
CGContextRef context = CGBitmapContextCreate(nullptr, (size_t)size.width, (size_t)size.height, 8,
|
|
84
|
+
0, space, kCGImageAlphaPremultipliedLast);
|
|
85
|
+
CGColorSpaceRelease(space);
|
|
86
|
+
CGContextSetRGBFillColor(context, 0.5, 0.5, 0.5, 1);
|
|
87
|
+
CGContextFillRect(context, CGRectMake(0, 0, size.width, size.height));
|
|
88
|
+
CGContextTranslateCTM(context, 0, size.height);
|
|
89
|
+
CGContextScaleCTM(context, 1, -1);
|
|
90
|
+
|
|
91
|
+
NSGraphicsContext *previous = NSGraphicsContext.currentContext;
|
|
92
|
+
NSGraphicsContext.currentContext = [NSGraphicsContext graphicsContextWithCGContext:context
|
|
93
|
+
flipped:YES];
|
|
94
|
+
[view drawRect:NSMakeRect(0, 0, size.width, size.height)];
|
|
95
|
+
NSGraphicsContext.currentContext = previous;
|
|
96
|
+
|
|
97
|
+
auto *pixels = static_cast<unsigned char *>(CGBitmapContextGetData(context));
|
|
98
|
+
const size_t stride = CGBitmapContextGetBytesPerRow(context);
|
|
99
|
+
const int red = pixels[(size_t)atY * stride + (size_t)atX * 4];
|
|
100
|
+
CGContextRelease(context);
|
|
101
|
+
return red;
|
|
102
|
+
}
|
|
103
|
+
|
|
76
104
|
} // namespace
|
|
77
105
|
|
|
106
|
+
// `indicatorStyle`, which is the thumb's colour: black over light content and
|
|
107
|
+
// white over dark, as core resolves it. The colour is held on the scroll view
|
|
108
|
+
// rather than on the overlay because the overlay comes and goes with the
|
|
109
|
+
// content size, which the second half of this asserts.
|
|
110
|
+
TEST(appkit_scrollbar_draws_in_the_colour_it_was_given) {
|
|
111
|
+
RnAppKitView *view = scrollViewWithIndicators(100, 100);
|
|
112
|
+
NSView *indicators = indicatorViewOf(view);
|
|
113
|
+
EXPECT(indicators != nil);
|
|
114
|
+
|
|
115
|
+
const CGSize size = CGSizeMake(100, 100);
|
|
116
|
+
const int grey = 128;
|
|
117
|
+
// The default: darker than the ground it sits on.
|
|
118
|
+
EXPECT(greyAt(indicators, size, 95, 10) < grey - 20);
|
|
119
|
+
|
|
120
|
+
const basalt::ScrollIndicatorColour white =
|
|
121
|
+
basalt::scrollIndicatorColourFor(basalt::ScrollIndicatorStyle::White);
|
|
122
|
+
[view setRnScrollIndicatorColourRed:white.red
|
|
123
|
+
green:white.green
|
|
124
|
+
blue:white.blue
|
|
125
|
+
alpha:white.alpha];
|
|
126
|
+
// And white: lighter than the same ground, which is the whole point of the
|
|
127
|
+
// prop -- over dark content a black thumb is not there.
|
|
128
|
+
EXPECT(greyAt(indicatorViewOf(view), size, 95, 10) > grey + 20);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
TEST(appkit_scrollbar_keeps_its_colour_across_an_overlay_it_lost) {
|
|
132
|
+
// The overlay is destroyed when everything fits and built again when it does
|
|
133
|
+
// not, so a colour kept only there would go back to black the first time a
|
|
134
|
+
// list shrank inside its viewport.
|
|
135
|
+
RnAppKitView *view = scrollViewWithIndicators(100, 100);
|
|
136
|
+
const basalt::ScrollIndicatorColour white =
|
|
137
|
+
basalt::scrollIndicatorColourFor(basalt::ScrollIndicatorStyle::White);
|
|
138
|
+
[view setRnScrollIndicatorColourRed:white.red
|
|
139
|
+
green:white.green
|
|
140
|
+
blue:white.blue
|
|
141
|
+
alpha:white.alpha];
|
|
142
|
+
|
|
143
|
+
[view setRnScrollIndicatorVerticalOffset:0
|
|
144
|
+
verticalLength:0
|
|
145
|
+
horizontalOffset:0
|
|
146
|
+
horizontalLength:0];
|
|
147
|
+
EXPECT(indicatorViewOf(view) == nil);
|
|
148
|
+
|
|
149
|
+
[view setRnScrollIndicatorVerticalOffset:0
|
|
150
|
+
verticalLength:24
|
|
151
|
+
horizontalOffset:0
|
|
152
|
+
horizontalLength:0];
|
|
153
|
+
NSView *rebuilt = indicatorViewOf(view);
|
|
154
|
+
EXPECT(rebuilt != nil);
|
|
155
|
+
EXPECT(greyAt(rebuilt, CGSizeMake(100, 100), 95, 10) > 148);
|
|
156
|
+
}
|
|
157
|
+
|
|
78
158
|
// The thumb exists, and it is the last subview -- which is the one AppKit draws
|
|
79
159
|
// on top of the others.
|
|
80
160
|
TEST(appkit_scrollbar_is_the_topmost_subview) {
|
|
@@ -130,3 +210,48 @@ TEST(appkit_scrollbar_goes_away_when_everything_fits) {
|
|
|
130
210
|
horizontalLength:0];
|
|
131
211
|
EXPECT(indicatorViewOf(view) == nil);
|
|
132
212
|
}
|
|
213
|
+
|
|
214
|
+
// The hairline that tells `default` from `black`, which is the whole
|
|
215
|
+
// difference between the two on iOS: `UIScrollViewIndicatorStyleDefault` is
|
|
216
|
+
// "black with a white border" and `.black` is black alone. Without it an app
|
|
217
|
+
// over dark content that left `indicatorStyle` alone got a black thumb on a
|
|
218
|
+
// black ground, which is a scrollbar nobody can see.
|
|
219
|
+
//
|
|
220
|
+
// Over grey rather than white, for the reason the colour test above is: the
|
|
221
|
+
// fill is a wash and both the fill and the edge have to be measurable against
|
|
222
|
+
// the same ground. The edge is read at the thumb's outermost column and the
|
|
223
|
+
// fill two points inside it, so the two cannot be confused with each other.
|
|
224
|
+
TEST(appkit_scrollbar_default_has_a_white_edge_and_black_does_not) {
|
|
225
|
+
RnAppKitView *view = scrollViewWithIndicators(100, 100);
|
|
226
|
+
const CGSize size = CGSizeMake(100, 100);
|
|
227
|
+
const int grey = 128;
|
|
228
|
+
|
|
229
|
+
const basalt::ScrollIndicatorColour fill =
|
|
230
|
+
basalt::scrollIndicatorColourFor(basalt::ScrollIndicatorStyle::Default);
|
|
231
|
+
const basalt::ScrollIndicatorColour edge =
|
|
232
|
+
basalt::scrollIndicatorBorderColourFor(basalt::ScrollIndicatorStyle::Default);
|
|
233
|
+
[view setRnScrollIndicatorColourRed:fill.red green:fill.green blue:fill.blue alpha:fill.alpha];
|
|
234
|
+
[view setRnScrollIndicatorBorderColourRed:edge.red
|
|
235
|
+
green:edge.green
|
|
236
|
+
blue:edge.blue
|
|
237
|
+
alpha:edge.alpha];
|
|
238
|
+
|
|
239
|
+
// The thumb is six points wide, two in from the right edge of a hundred, so
|
|
240
|
+
// its columns are 92 to 97. The left-hand edge column carries the hairline
|
|
241
|
+
// and the middle carries the fill.
|
|
242
|
+
const int edgeColumn = 92;
|
|
243
|
+
const int fillColumn = 95;
|
|
244
|
+
EXPECT(greyAt(indicatorViewOf(view), size, edgeColumn, 20) > grey + 10);
|
|
245
|
+
EXPECT(greyAt(indicatorViewOf(view), size, fillColumn, 20) < grey - 20);
|
|
246
|
+
|
|
247
|
+
// And `black`, which is the same fill with no edge: the column that was
|
|
248
|
+
// lighter than its ground is not any more.
|
|
249
|
+
const basalt::ScrollIndicatorColour none =
|
|
250
|
+
basalt::scrollIndicatorBorderColourFor(basalt::ScrollIndicatorStyle::Black);
|
|
251
|
+
[view setRnScrollIndicatorBorderColourRed:none.red
|
|
252
|
+
green:none.green
|
|
253
|
+
blue:none.blue
|
|
254
|
+
alpha:none.alpha];
|
|
255
|
+
EXPECT(greyAt(indicatorViewOf(view), size, edgeColumn, 20) <= grey);
|
|
256
|
+
EXPECT(greyAt(indicatorViewOf(view), size, fillColumn, 20) < grey - 20);
|
|
257
|
+
}
|
|
@@ -0,0 +1,371 @@
|
|
|
1
|
+
// `<ScrollView>` on AppKit, as far as a test without a window reaches.
|
|
2
|
+
//
|
|
3
|
+
// Most of what this host's scroll view does needs a display: the wheel arrives
|
|
4
|
+
// as an NSEvent, the momentum is the system's, and the indicator is a view that
|
|
5
|
+
// has to be drawn. What does not need one is the part React Native drives --
|
|
6
|
+
// props arriving as mutations and the offset that results -- and
|
|
7
|
+
// `AppKitScrollViewManager` takes an emitter lookup and a view, so it can be
|
|
8
|
+
// built on its own exactly as the GTK suite builds its own manager.
|
|
9
|
+
//
|
|
10
|
+
// An EventEmitter made by hand has no EventDispatcher, so the lookup answering
|
|
11
|
+
// nothing is honest: what is observable here is where the content sits.
|
|
12
|
+
|
|
13
|
+
#include "TestHarness.h"
|
|
14
|
+
|
|
15
|
+
#include "AppKitMountingManager.h"
|
|
16
|
+
#include "AppKitScrollView.h"
|
|
17
|
+
#include "ScrollIndicator.h"
|
|
18
|
+
|
|
19
|
+
#import "RnAppKitView.h"
|
|
20
|
+
|
|
21
|
+
#include <react/renderer/components/scrollview/ScrollViewProps.h>
|
|
22
|
+
#include <react/renderer/components/scrollview/ScrollViewShadowNode.h>
|
|
23
|
+
#include <react/renderer/components/scrollview/ScrollViewState.h>
|
|
24
|
+
|
|
25
|
+
#include <memory>
|
|
26
|
+
#include <optional>
|
|
27
|
+
#include <sstream>
|
|
28
|
+
|
|
29
|
+
using facebook::react::EventEmitter;
|
|
30
|
+
using facebook::react::LayoutMetrics;
|
|
31
|
+
using facebook::react::MountingTransaction;
|
|
32
|
+
using facebook::react::ScrollViewProps;
|
|
33
|
+
using facebook::react::ScrollViewShadowNode;
|
|
34
|
+
using facebook::react::ScrollViewState;
|
|
35
|
+
using facebook::react::ShadowNodeFamily;
|
|
36
|
+
using facebook::react::ShadowView;
|
|
37
|
+
using facebook::react::ShadowViewMutation;
|
|
38
|
+
using facebook::react::ShadowViewMutationList;
|
|
39
|
+
using facebook::react::SurfaceId;
|
|
40
|
+
using facebook::react::TransactionTelemetry;
|
|
41
|
+
using facebook::react::ViewProps;
|
|
42
|
+
using facebook::react::Tag;
|
|
43
|
+
|
|
44
|
+
// `Point`, `Rect` and `Size` are not imported by name: Carbon declares all
|
|
45
|
+
// three, through Cocoa, and an unqualified one is ambiguous in an Objective-C++
|
|
46
|
+
// translation unit. Aliases rather than a `using`, so the geometry below reads
|
|
47
|
+
// the way it does in the C++ suites.
|
|
48
|
+
using RnPoint = facebook::react::Point;
|
|
49
|
+
using RnRect = facebook::react::Rect;
|
|
50
|
+
using RnSize = facebook::react::Size;
|
|
51
|
+
|
|
52
|
+
namespace {
|
|
53
|
+
|
|
54
|
+
constexpr SurfaceId kSurfaceId = 1;
|
|
55
|
+
|
|
56
|
+
ShadowView makeScrollView(Tag tag,
|
|
57
|
+
float contentHeight,
|
|
58
|
+
RnPoint contentOffset = RnPoint{0, 0},
|
|
59
|
+
bool centerContent = false,
|
|
60
|
+
facebook::react::ScrollViewIndicatorStyle indicatorStyle =
|
|
61
|
+
facebook::react::ScrollViewIndicatorStyle::Default,
|
|
62
|
+
std::optional<facebook::react::ScrollViewMaintainVisibleContentPosition>
|
|
63
|
+
maintainVisible = std::nullopt) {
|
|
64
|
+
LayoutMetrics metrics;
|
|
65
|
+
metrics.frame = {.origin = {.x = 0, .y = 0}, .size = {.width = 400, .height = 300}};
|
|
66
|
+
|
|
67
|
+
auto props = std::make_shared<ScrollViewProps>();
|
|
68
|
+
props->contentOffset = contentOffset;
|
|
69
|
+
props->centerContent = centerContent;
|
|
70
|
+
props->indicatorStyle = indicatorStyle;
|
|
71
|
+
props->maintainVisibleContentPosition = maintainVisible;
|
|
72
|
+
|
|
73
|
+
ScrollViewState data;
|
|
74
|
+
// The prop and the state carry the same value, which is what React Native
|
|
75
|
+
// produces: `ScrollViewShadowNode::initialStateData` seeds the state from the
|
|
76
|
+
// prop. A test that set only one of them would be testing a shape that never
|
|
77
|
+
// arrives.
|
|
78
|
+
data.contentOffset = contentOffset;
|
|
79
|
+
data.contentBoundingRect = RnRect{.origin = {.x = 0, .y = 0},
|
|
80
|
+
.size = RnSize{.width = 400, .height = contentHeight}};
|
|
81
|
+
|
|
82
|
+
ShadowView view;
|
|
83
|
+
view.componentName = "ScrollView";
|
|
84
|
+
view.surfaceId = kSurfaceId;
|
|
85
|
+
view.tag = tag;
|
|
86
|
+
view.props = props;
|
|
87
|
+
view.layoutMetrics = metrics;
|
|
88
|
+
// An empty family: nothing can be committed back through it, so the state
|
|
89
|
+
// write-back is not observable from here.
|
|
90
|
+
view.state = std::make_shared<const ScrollViewShadowNode::ConcreteState>(
|
|
91
|
+
std::make_shared<const ScrollViewState>(data), ShadowNodeFamily::Weak{});
|
|
92
|
+
return view;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A manager with one scroll view registered against a real RnAppKitView.
|
|
96
|
+
struct Scroller {
|
|
97
|
+
basalt::AppKitScrollViewManager manager;
|
|
98
|
+
RnAppKitView *view;
|
|
99
|
+
Tag tag;
|
|
100
|
+
|
|
101
|
+
Scroller(Tag tag,
|
|
102
|
+
float contentHeight,
|
|
103
|
+
RnPoint contentOffset = RnPoint{0, 0},
|
|
104
|
+
bool centerContent = false,
|
|
105
|
+
facebook::react::ScrollViewIndicatorStyle indicatorStyle =
|
|
106
|
+
facebook::react::ScrollViewIndicatorStyle::Default)
|
|
107
|
+
: manager([](Tag) { return EventEmitter::Shared{}; }),
|
|
108
|
+
view([RnAppKitView viewWithTag:static_cast<int>(tag)]),
|
|
109
|
+
tag(tag) {
|
|
110
|
+
[view setRnFrameX:0 y:0 width:400 height:300];
|
|
111
|
+
manager.update(
|
|
112
|
+
view,
|
|
113
|
+
makeScrollView(tag, contentHeight, contentOffset, centerContent, indicatorStyle));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
~Scroller() { manager.remove(tag); }
|
|
117
|
+
|
|
118
|
+
Scroller(const Scroller &) = delete;
|
|
119
|
+
Scroller &operator=(const Scroller &) = delete;
|
|
120
|
+
|
|
121
|
+
double offsetY() const { return view.rnScrollOffset.y; }
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
} // namespace
|
|
125
|
+
|
|
126
|
+
// --- contentOffset ----------------------------------------------------------
|
|
127
|
+
//
|
|
128
|
+
// The first value an app writes reaches here through the *state*, which
|
|
129
|
+
// ScrollViewShadowNode seeds from the prop, and every host already adopted it.
|
|
130
|
+
// A later change arrives as an ordinary prop update and reached no host until
|
|
131
|
+
// 2026-10-10.
|
|
132
|
+
|
|
133
|
+
TEST(appkit_scrollview_an_initial_content_offset_opens_the_list_there) {
|
|
134
|
+
@autoreleasepool {
|
|
135
|
+
Scroller scroller(10, 4000, RnPoint{0, 120});
|
|
136
|
+
EXPECT_NEAR(scroller.offsetY(), 120.0, 0.001);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
TEST(appkit_scrollview_a_changed_content_offset_moves_the_list) {
|
|
141
|
+
@autoreleasepool {
|
|
142
|
+
Scroller scroller(11, 4000, RnPoint{0, 120});
|
|
143
|
+
scroller.manager.update(scroller.view, makeScrollView(11, 4000, RnPoint{0, 700}));
|
|
144
|
+
EXPECT_NEAR(scroller.offsetY(), 700.0, 0.001);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
TEST(appkit_scrollview_an_unchanged_content_offset_leaves_the_list_where_it_is) {
|
|
149
|
+
@autoreleasepool {
|
|
150
|
+
// The reason this is a "when it changes" rule rather than a comparison
|
|
151
|
+
// against the list: React Native re-renders for all sorts of reasons, and
|
|
152
|
+
// every one of them carries the same `contentOffset` the app wrote once.
|
|
153
|
+
Scroller scroller(12, 4000, RnPoint{0, 120});
|
|
154
|
+
scroller.manager.dispatchCommand(
|
|
155
|
+
scroller.tag, "scrollTo", folly::dynamic::array(0, 900, false));
|
|
156
|
+
EXPECT_NEAR(scroller.offsetY(), 900.0, 0.001);
|
|
157
|
+
|
|
158
|
+
scroller.manager.update(scroller.view, makeScrollView(12, 4000, RnPoint{0, 120}));
|
|
159
|
+
EXPECT_NEAR(scroller.offsetY(), 900.0, 0.001);
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
TEST(appkit_scrollview_a_content_offset_past_the_content_is_clamped) {
|
|
164
|
+
@autoreleasepool {
|
|
165
|
+
// 300 of viewport over 500 of content: 200 is as far as it goes.
|
|
166
|
+
Scroller scroller(13, 500, RnPoint{0, 400});
|
|
167
|
+
EXPECT_NEAR(scroller.offsetY(), 200.0, 0.001);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// --- centerContent ----------------------------------------------------------
|
|
172
|
+
//
|
|
173
|
+
// The arithmetic is core/ScrollBounds.h's: an inset of half the slack at each
|
|
174
|
+
// end, which leaves one offset to rest at. What this asserts is that the prop
|
|
175
|
+
// reaches it on this host and that the clamp puts the content there.
|
|
176
|
+
|
|
177
|
+
TEST(appkit_scrollview_center_content_centres_content_that_fits) {
|
|
178
|
+
@autoreleasepool {
|
|
179
|
+
// 200 of content in a 300 container: 100 of slack, so the content sits 50
|
|
180
|
+
// further down, which is an offset of -50.
|
|
181
|
+
Scroller scroller(20, 200, RnPoint{0, 0}, /*centerContent=*/true);
|
|
182
|
+
EXPECT_NEAR(scroller.offsetY(), -50.0, 0.001);
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
TEST(appkit_scrollview_content_that_fits_is_not_centred_unless_asked) {
|
|
187
|
+
@autoreleasepool {
|
|
188
|
+
Scroller scroller(21, 200);
|
|
189
|
+
EXPECT_NEAR(scroller.offsetY(), 0.0, 0.001);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
TEST(appkit_scrollview_center_content_does_nothing_to_a_list_that_scrolls) {
|
|
194
|
+
@autoreleasepool {
|
|
195
|
+
Scroller scroller(22, 4000, RnPoint{0, 0}, /*centerContent=*/true);
|
|
196
|
+
EXPECT_NEAR(scroller.offsetY(), 0.0, 0.001);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// --- indicatorStyle ---------------------------------------------------------
|
|
201
|
+
//
|
|
202
|
+
// The prop reaching the thumb's colour. What that colour draws as is
|
|
203
|
+
// tests/test_appkit_scrollbar.mm's question, where the overlay is rasterised;
|
|
204
|
+
// this is the wiring, and the colour is readable straight off the view.
|
|
205
|
+
|
|
206
|
+
TEST(appkit_scrollview_indicator_style_reaches_the_thumb) {
|
|
207
|
+
@autoreleasepool {
|
|
208
|
+
Scroller white(30,
|
|
209
|
+
4000,
|
|
210
|
+
RnPoint{0, 0},
|
|
211
|
+
false,
|
|
212
|
+
facebook::react::ScrollViewIndicatorStyle::White);
|
|
213
|
+
NSColor *const colour =
|
|
214
|
+
[white.view.rnScrollIndicatorColour colorUsingColorSpace:NSColorSpace.sRGBColorSpace];
|
|
215
|
+
EXPECT(colour != nil);
|
|
216
|
+
if (colour == nil) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
EXPECT_NEAR(colour.redComponent, 1.0, 0.001);
|
|
220
|
+
EXPECT_NEAR(colour.greenComponent, 1.0, 0.001);
|
|
221
|
+
EXPECT_NEAR(colour.blueComponent, 1.0, 0.001);
|
|
222
|
+
EXPECT_NEAR(colour.alphaComponent, basalt::kScrollIndicatorAlpha, 0.001);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
TEST(appkit_scrollview_a_default_indicator_is_black) {
|
|
227
|
+
@autoreleasepool {
|
|
228
|
+
// Black rather than nothing: the manager answers the prop on every update,
|
|
229
|
+
// so a list that never set it still has a colour -- core's.
|
|
230
|
+
Scroller plain(31, 4000);
|
|
231
|
+
NSColor *const colour =
|
|
232
|
+
[plain.view.rnScrollIndicatorColour colorUsingColorSpace:NSColorSpace.sRGBColorSpace];
|
|
233
|
+
EXPECT(colour != nil);
|
|
234
|
+
if (colour == nil) {
|
|
235
|
+
return;
|
|
236
|
+
}
|
|
237
|
+
EXPECT_NEAR(colour.redComponent, 0.0, 0.001);
|
|
238
|
+
EXPECT_NEAR(colour.alphaComponent, basalt::kScrollIndicatorAlpha, 0.001);
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// --- maintainVisibleContentPosition ------------------------------------------
|
|
243
|
+
//
|
|
244
|
+
// Through the *mounting* manager rather than the scroll manager the rest of
|
|
245
|
+
// this file uses: the two halves of this prop run around a transaction, so a
|
|
246
|
+
// test that called them itself would not be testing that anything calls them.
|
|
247
|
+
// The arithmetic is core/tests/test_scroll_visible_position.cpp's.
|
|
248
|
+
namespace {
|
|
249
|
+
|
|
250
|
+
ShadowView makePlainView(Tag tag, float x, float y, float width, float height) {
|
|
251
|
+
LayoutMetrics metrics;
|
|
252
|
+
metrics.frame = {.origin = {.x = x, .y = y}, .size = {.width = width, .height = height}};
|
|
253
|
+
|
|
254
|
+
ShadowView view;
|
|
255
|
+
view.componentName = "View";
|
|
256
|
+
view.surfaceId = kSurfaceId;
|
|
257
|
+
view.tag = tag;
|
|
258
|
+
view.props = std::make_shared<ViewProps>();
|
|
259
|
+
view.layoutMetrics = metrics;
|
|
260
|
+
return view;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
void applyTo(basalt::AppKitMountingManager &manager, ShadowViewMutationList &&mutations) {
|
|
264
|
+
manager.applyTransaction(
|
|
265
|
+
kSurfaceId, MountingTransaction(kSurfaceId, 1, std::move(mutations), TransactionTelemetry{}));
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
// A ScrollView with a content view and two hundred-point rows under it, which
|
|
269
|
+
// is the smallest tree the prop can be asked about.
|
|
270
|
+
void mountList(basalt::AppKitMountingManager &manager, const ShadowView &scroller) {
|
|
271
|
+
ShadowViewMutationList mutations;
|
|
272
|
+
mutations.push_back(ShadowViewMutation::CreateMutation(scroller));
|
|
273
|
+
mutations.push_back(ShadowViewMutation::InsertMutation(kSurfaceId, scroller, 0));
|
|
274
|
+
const ShadowView content = makePlainView(11, 0, 0, 400, 1000);
|
|
275
|
+
mutations.push_back(ShadowViewMutation::CreateMutation(content));
|
|
276
|
+
mutations.push_back(ShadowViewMutation::InsertMutation(10, content, 0));
|
|
277
|
+
const ShadowView first = makePlainView(12, 0, 0, 400, 100);
|
|
278
|
+
const ShadowView second = makePlainView(13, 0, 100, 400, 100);
|
|
279
|
+
mutations.push_back(ShadowViewMutation::CreateMutation(first));
|
|
280
|
+
mutations.push_back(ShadowViewMutation::InsertMutation(11, first, 0));
|
|
281
|
+
mutations.push_back(ShadowViewMutation::CreateMutation(second));
|
|
282
|
+
mutations.push_back(ShadowViewMutation::InsertMutation(11, second, 1));
|
|
283
|
+
applyTo(manager, std::move(mutations));
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// The first row grows by a hundred points, which pushes the second one down by
|
|
287
|
+
// the same -- the shape an insertion above the visible content has.
|
|
288
|
+
void growTheFirstRow(basalt::AppKitMountingManager &manager) {
|
|
289
|
+
ShadowViewMutationList mutations;
|
|
290
|
+
mutations.push_back(ShadowViewMutation::UpdateMutation(
|
|
291
|
+
makePlainView(12, 0, 0, 400, 100), makePlainView(12, 0, 0, 400, 200), kSurfaceId));
|
|
292
|
+
mutations.push_back(ShadowViewMutation::UpdateMutation(
|
|
293
|
+
makePlainView(13, 0, 100, 400, 100), makePlainView(13, 0, 200, 400, 100), kSurfaceId));
|
|
294
|
+
applyTo(manager, std::move(mutations));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
double offsetOf(basalt::AppKitMountingManager &manager, Tag tag) {
|
|
298
|
+
return manager.viewForTag(tag).rnScrollOffset.y;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
facebook::react::ScrollViewMaintainVisibleContentPosition maintainFrom(int minIndex) {
|
|
302
|
+
facebook::react::ScrollViewMaintainVisibleContentPosition maintain;
|
|
303
|
+
maintain.minIndexForVisible = minIndex;
|
|
304
|
+
return maintain;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
ShadowView listScroller(
|
|
308
|
+
std::optional<facebook::react::ScrollViewMaintainVisibleContentPosition> maintain) {
|
|
309
|
+
return makeScrollView(10,
|
|
310
|
+
1000,
|
|
311
|
+
RnPoint{0, 0},
|
|
312
|
+
false,
|
|
313
|
+
facebook::react::ScrollViewIndicatorStyle::Default,
|
|
314
|
+
maintain);
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
} // namespace
|
|
318
|
+
|
|
319
|
+
TEST(appkit_scrollview_maintain_visible_content_position_holds_the_content_still) {
|
|
320
|
+
@autoreleasepool {
|
|
321
|
+
basalt::AppKitMountingManager manager;
|
|
322
|
+
RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
|
|
323
|
+
[root setRnFrameX:0 y:0 width:400 height:300];
|
|
324
|
+
|
|
325
|
+
mountList(manager, listScroller(maintainFrom(0)));
|
|
326
|
+
|
|
327
|
+
// Scrolled to 150: the first row is entirely above the viewport and the
|
|
328
|
+
// second is the first with anything on screen, so the second is held still.
|
|
329
|
+
manager.applyCommand(10, "scrollTo", folly::dynamic::array(0, 150, false));
|
|
330
|
+
EXPECT_NEAR(offsetOf(manager, 10), 150.0, 0.001);
|
|
331
|
+
|
|
332
|
+
growTheFirstRow(manager);
|
|
333
|
+
EXPECT_NEAR(offsetOf(manager, 10), 250.0, 0.001);
|
|
334
|
+
|
|
335
|
+
manager.destroySurfaceRoot(kSurfaceId);
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
TEST(appkit_scrollview_a_list_that_did_not_ask_lets_the_content_jump) {
|
|
340
|
+
@autoreleasepool {
|
|
341
|
+
basalt::AppKitMountingManager manager;
|
|
342
|
+
RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
|
|
343
|
+
[root setRnFrameX:0 y:0 width:400 height:300];
|
|
344
|
+
|
|
345
|
+
mountList(manager, listScroller(std::nullopt));
|
|
346
|
+
manager.applyCommand(10, "scrollTo", folly::dynamic::array(0, 150, false));
|
|
347
|
+
growTheFirstRow(manager);
|
|
348
|
+
|
|
349
|
+
EXPECT_NEAR(offsetOf(manager, 10), 150.0, 0.001);
|
|
350
|
+
|
|
351
|
+
manager.destroySurfaceRoot(kSurfaceId);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
TEST(appkit_scrollview_min_index_for_visible_skips_a_header) {
|
|
356
|
+
@autoreleasepool {
|
|
357
|
+
// At the top of the list the first row is the visible one, and an index of
|
|
358
|
+
// 1 takes it out of the running -- so the second row is watched, and that
|
|
359
|
+
// one moves.
|
|
360
|
+
basalt::AppKitMountingManager manager;
|
|
361
|
+
RnAppKitView *root = manager.createSurfaceRoot(kSurfaceId);
|
|
362
|
+
[root setRnFrameX:0 y:0 width:400 height:300];
|
|
363
|
+
|
|
364
|
+
mountList(manager, listScroller(maintainFrom(1)));
|
|
365
|
+
EXPECT_NEAR(offsetOf(manager, 10), 0.0, 0.001);
|
|
366
|
+
growTheFirstRow(manager);
|
|
367
|
+
EXPECT_NEAR(offsetOf(manager, 10), 100.0, 0.001);
|
|
368
|
+
|
|
369
|
+
manager.destroySurfaceRoot(kSurfaceId);
|
|
370
|
+
}
|
|
371
|
+
}
|