basalt-appkit 0.0.0-rc5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. package/native/CMakeLists.txt +16 -2
  2. package/native/appkit/AppKitAppWindow.h +24 -0
  3. package/native/appkit/AppKitImageLoader.h +48 -1
  4. package/native/appkit/AppKitImageLoader.mm +228 -10
  5. package/native/appkit/AppKitMenuBar.mm +58 -0
  6. package/native/appkit/AppKitMountingManager.h +34 -0
  7. package/native/appkit/AppKitMountingManager.mm +576 -9
  8. package/native/appkit/AppKitScrollView.h +48 -0
  9. package/native/appkit/AppKitScrollView.mm +210 -0
  10. package/native/appkit/AppKitTextInput.h +38 -1
  11. package/native/appkit/AppKitTextInput.mm +235 -8
  12. package/native/appkit/AppKitTextPeer.h +60 -0
  13. package/native/appkit/AppKitTextPeer.mm +307 -0
  14. package/native/appkit/AppKitTouchDispatcher.h +56 -0
  15. package/native/appkit/AppKitTouchDispatcher.mm +140 -0
  16. package/native/appkit/AppKitWindowControl.mm +38 -3
  17. package/native/appkit/ComponentRegistryAppKit.mm +20 -0
  18. package/native/appkit/CoreTextLayout.h +64 -2
  19. package/native/appkit/CoreTextLayout.mm +431 -71
  20. package/native/appkit/CoreTextLayoutManager.mm +57 -6
  21. package/native/appkit/FontRegistryCoreText.mm +6 -5
  22. package/native/appkit/PlatformServicesAppKit.mm +101 -20
  23. package/native/appkit/RnAppKitView.h +492 -0
  24. package/native/appkit/RnAppKitView.mm +2266 -23
  25. package/native/appkit/RnTextLayout.h +106 -0
  26. package/native/appkit/RnTextLayout.mm +321 -2
  27. package/native/appkit/main_appkit.mm +174 -3
  28. package/native/tests/test_appkit_accessibility.mm +289 -0
  29. package/native/tests/test_appkit_borders.mm +975 -0
  30. package/native/tests/test_appkit_fonts.mm +203 -0
  31. package/native/tests/test_appkit_image.mm +392 -17
  32. package/native/tests/test_appkit_input.mm +209 -0
  33. package/native/tests/test_appkit_mounting.mm +360 -0
  34. package/native/tests/test_appkit_scrollbar.mm +125 -0
  35. package/native/tests/test_appkit_scrollview.mm +371 -0
  36. package/native/tests/test_appkit_selection.mm +349 -0
  37. package/native/tests/test_appkit_services.mm +51 -1
  38. package/native/tests/test_appkit_text.mm +1628 -0
  39. package/native/tests/test_appkit_textinput.mm +819 -0
  40. package/native/tests/test_appkit_titlebar.mm +43 -0
  41. package/native/tests/test_appkit_view.mm +609 -0
  42. package/package.json +4 -3
@@ -38,13 +38,16 @@
38
38
  #include "ScrollBounds.h"
39
39
  #include "ScrollIndicator.h"
40
40
  #include "ScrollSnap.h"
41
+ #include "ScrollVisiblePosition.h"
41
42
 
42
43
  #include <react/renderer/components/scrollview/ScrollViewShadowNode.h>
43
44
  #include <react/renderer/core/EventEmitter.h>
44
45
  #include <react/renderer/mounting/ShadowView.h>
45
46
 
46
47
  #include <functional>
48
+ #include <optional>
47
49
  #include <unordered_map>
50
+ #include <vector>
48
51
 
49
52
  namespace basalt {
50
53
 
@@ -65,6 +68,17 @@ class AppKitScrollViewManager {
65
68
  void update(RnAppKitView *view, const facebook::react::ShadowView &shadowView);
66
69
 
67
70
  void remove(facebook::react::Tag tag);
71
+ // `maintainVisibleContentPosition`, in the two halves a mounting transaction
72
+ // needs: the children are measured before the mutations and the offset is
73
+ // moved after, so content under the eye stays where it is while items are
74
+ // inserted above it. The arithmetic is core/ScrollVisiblePosition.h's.
75
+ //
76
+ // Public because the mounting manager is what knows when a transaction starts
77
+ // and ends, which is upstream's division too: `mountingTransactionWillMount`
78
+ // and `...DidMount` on iOS.
79
+ void prepareMaintainVisiblePosition();
80
+ void adjustForMaintainVisiblePosition();
81
+
68
82
 
69
83
  // ScrollView's imperative commands: scrollTo, scrollToEnd. Returns false if
70
84
  // the command is not one this handles.
@@ -124,6 +138,35 @@ class AppKitScrollViewManager {
124
138
 
125
139
  double offsetX{0};
126
140
  double offsetY{0};
141
+
142
+ // `maintainVisibleContentPosition`, as React Native hands it over: a
143
+ // minimum index and an optional threshold, or nothing at all when the list
144
+ // never asked.
145
+ std::optional<facebook::react::ScrollViewMaintainVisibleContentPosition> maintainVisible{};
146
+ // The child being held still across the current transaction, measured by
147
+ // `prepareMaintainVisiblePosition` and used by the adjust that follows.
148
+ ScrollPinnedChild pinned{};
149
+ // `indicatorStyle`: which colour the overlay thumb is drawn in. Held as
150
+ // core's enum rather than React Native's, the view layer below knowing
151
+ // nothing about either.
152
+ ScrollIndicatorStyle indicatorStyle{ScrollIndicatorStyle::Default};
153
+
154
+ // `centerContent`: whether content smaller than the container is centred in
155
+ // it. Acted on through the content inset, which is how iOS does it too --
156
+ // see `update`.
157
+ bool centerContent{false};
158
+
159
+ // The last `contentOffset` prop seen, and nothing when none has been.
160
+ //
161
+ // The *initial* value of that prop arrives through the state rather than
162
+ // here -- `ScrollViewShadowNode::initialStateData` seeds the state with it
163
+ // -- and is adopted below. What this is for is every later change, which
164
+ // arrives as an ordinary prop update and is applied when it *changes*,
165
+ // never merely when it differs from where the list is: an app that sets the
166
+ // prop once and re-renders for an unrelated reason must not have the list
167
+ // dragged back under the person reading it. The same rule a `<TextInput>`'s
168
+ // `text` is under, for the same reason.
169
+ std::optional<facebook::react::Point> lastPropContentOffset{};
127
170
  double lastEmitSeconds{0};
128
171
  bool dragging{false};
129
172
  // Coasting after the fingers left. Tracked for the same reason `dragging`
@@ -140,6 +183,11 @@ class AppKitScrollViewManager {
140
183
  double animationLastSeconds{0};
141
184
  };
142
185
 
186
+ // The rows of the content view, on whichever axis scrolls, for
187
+ // `maintainVisibleContentPosition`. The one part of that prop a toolkit
188
+ // knows: core decides what to do with the boxes.
189
+ std::vector<ScrollChildBox> contentChildBoxes(const Entry &entry) const;
190
+
143
191
  void applyOffset(Entry &entry, double x, double y, bool emitEvent);
144
192
  // Recomputes both overlay scrollbars and hands them to the view to draw.
145
193
  // Called wherever the offset, the content size or the container size can
@@ -1,5 +1,7 @@
1
1
  #import "AppKitScrollView.h"
2
2
 
3
+ #include "ScrollSnap.h"
4
+
3
5
  #import <QuartzCore/QuartzCore.h>
4
6
 
5
7
  #include <react/renderer/components/scrollview/ScrollEvent.h>
@@ -178,6 +180,71 @@ void AppKitScrollViewManager::update(RnAppKitView *view, const ShadowView &shado
178
180
  entry.snap.alignment = ScrollSnapAlignment::Start;
179
181
  break;
180
182
  }
183
+ // The three modifiers of snapping, all of which `core/ScrollSnap.h` acts
184
+ // on: whether the content's own edges count as snap points beside the
185
+ // listed ones, and whether a fling across a spacing carries or stops at the
186
+ // next point.
187
+ // `centerContent`, applied below once the content size is known.
188
+ entry.centerContent = props->centerContent;
189
+
190
+ // `maintainVisibleContentPosition`, remembered for the next transaction:
191
+ // the work it asks for happens around the mutations rather than while a
192
+ // prop is being read. See `prepareMaintainVisiblePosition`.
193
+ entry.maintainVisible = props->maintainVisibleContentPosition;
194
+
195
+ // `indicatorStyle`, resolved to a colour by core so that all three hosts
196
+ // draw the same thumb for the same prop.
197
+ switch (props->indicatorStyle) {
198
+ case facebook::react::ScrollViewIndicatorStyle::White:
199
+ entry.indicatorStyle = ScrollIndicatorStyle::White;
200
+ break;
201
+ case facebook::react::ScrollViewIndicatorStyle::Black:
202
+ entry.indicatorStyle = ScrollIndicatorStyle::Black;
203
+ break;
204
+ case facebook::react::ScrollViewIndicatorStyle::Default:
205
+ default:
206
+ entry.indicatorStyle = ScrollIndicatorStyle::Default;
207
+ break;
208
+ }
209
+
210
+ entry.snap.snapToStart = props->snapToStart;
211
+ entry.snap.snapToEnd = props->snapToEnd;
212
+ entry.snap.disableIntervalMomentum = props->disableIntervalMomentum;
213
+
214
+ // `contentOffset`, which an app writes to open a list part way down. Its
215
+ // first value reached here through the state and is adopted below; this is
216
+ // every later change, applied the way upstream applies it --
217
+ // `oldScrollViewProps.contentOffset != newScrollViewProps.contentOffset` in
218
+ // `RCTScrollViewComponentView` -- rather than whenever the prop differs
219
+ // from where the list is.
220
+ //
221
+ // The offset is set rather than animated to, which is what assigning
222
+ // `UIScrollView.contentOffset` does, and the clamp below is what keeps a
223
+ // value past the end of the content from showing empty space.
224
+ if (!entry.lastPropContentOffset.has_value()
225
+ || *entry.lastPropContentOffset != props->contentOffset) {
226
+ entry.lastPropContentOffset = props->contentOffset;
227
+ entry.offsetX = props->contentOffset.x;
228
+ entry.offsetY = props->contentOffset.y;
229
+ // Whatever the list was animating towards is somewhere the app has just
230
+ // said it does not want to be. No fling to stop: the momentum on this
231
+ // host is not this host's to run.
232
+ stopAnimation(entry);
233
+ }
234
+ // `decelerationRate`, which this host does not animate with -- the fling
235
+ // itself is AppKit's, at its own friction -- but does need for the
236
+ // *projection*: where a fling would have landed is the point a snap is
237
+ // chosen from, and a fast list stops sooner. Zero is what an app that
238
+ // never set the prop leaves behind.
239
+ if (props->decelerationRate > 0) {
240
+ entry.snap.decelerationRate = static_cast<double>(props->decelerationRate);
241
+ }
242
+
243
+ // What all of that came to, for the tree dump. None of these props is
244
+ // visible any other way: they decide where a fling *ends*, and a dump is
245
+ // taken when nothing is moving. The string is core's, so the three hosts
246
+ // cannot drift; see describeScrollSnap.
247
+ view.rnScrollSnap = @(basalt::describeScrollSnap(entry.snap).c_str());
181
248
  }
182
249
 
183
250
  if (const auto state =
@@ -195,6 +262,27 @@ void AppKitScrollViewManager::update(RnAppKitView *view, const ShadowView &shado
195
262
  }
196
263
  }
197
264
 
265
+ // `centerContent`: content smaller than its container sits in the middle of
266
+ // it. Expressed as an inset of half the slack at each end, which leaves the
267
+ // range below a single offset to be at -- so the clamp that was already here
268
+ // is what centres the content and what keeps it centred.
269
+ //
270
+ // It replaces the app's own `contentInset` rather than adding to it, which is
271
+ // what iOS does: `centerContentIfNeeded` assigns the inset outright, and
272
+ // `RCTScrollViewComponentView` skips the `contentInset` prop entirely while
273
+ // `centerContent` is set. Recomputed here rather than where the prop is read
274
+ // because it needs the content size, which arrives with the state.
275
+ if (entry.centerContent) {
276
+ const ScrollAxisInsets horizontal =
277
+ centeringInsets(entry.containerSize.width, entry.contentSize.width);
278
+ const ScrollAxisInsets vertical =
279
+ centeringInsets(entry.containerSize.height, entry.contentSize.height);
280
+ entry.contentInset.left = static_cast<facebook::react::Float>(horizontal.leading);
281
+ entry.contentInset.right = static_cast<facebook::react::Float>(horizontal.trailing);
282
+ entry.contentInset.top = static_cast<facebook::react::Float>(vertical.leading);
283
+ entry.contentInset.bottom = static_cast<facebook::react::Float>(vertical.trailing);
284
+ }
285
+
198
286
  // Re-clamp: the content may have shrunk under a scrolled offset.
199
287
  const double x = clampScrollOffset(entry.offsetX,
200
288
  entry.containerSize.width,
@@ -212,6 +300,117 @@ void AppKitScrollViewManager::update(RnAppKitView *view, const ShadowView &shado
212
300
  updateIndicators(entry);
213
301
  }
214
302
 
303
+ // ---------------------------------------------------------------------------
304
+ // maintainVisibleContentPosition
305
+ //
306
+ // Two halves around a mounting transaction: measure the child being held still
307
+ // before the mutations, and move the offset by however far it moved after. The
308
+ // arithmetic -- which child, how far, and whether the list was near enough to
309
+ // the start to follow the new content instead -- is core/ScrollVisiblePosition.h.
310
+ //
311
+ // What is here is the part only a toolkit knows: where this host's children
312
+ // are. React Native wraps a ScrollView's children in exactly one content view,
313
+ // so the rows are that view's children.
314
+ // ---------------------------------------------------------------------------
315
+
316
+ namespace {
317
+
318
+ // Whether the list scrolls sideways, decided the way upstream decides it: by
319
+ // the content being wider than the view rather than by a prop, there being no
320
+ // prop for it.
321
+ bool scrollsHorizontally(const Size &container, const Size &content) {
322
+ return content.width > container.width;
323
+ }
324
+
325
+ } // namespace
326
+
327
+ std::vector<ScrollChildBox> AppKitScrollViewManager::contentChildBoxes(const Entry &entry) const {
328
+ std::vector<ScrollChildBox> boxes;
329
+ if (entry.view == nil) {
330
+ return boxes;
331
+ }
332
+ // The content view, which React Native always makes and always makes exactly
333
+ // one of. `rnChildrenInPaintOrder` rather than `subviews` because the overlay
334
+ // scrollbar is a subview too and is not a row; for children with no zIndex --
335
+ // which rows do not have -- that order is the order they were inserted in.
336
+ RnAppKitView *const content = [entry.view rnChildrenInPaintOrder].firstObject;
337
+ if (content == nil) {
338
+ return boxes;
339
+ }
340
+
341
+ const bool horizontal = scrollsHorizontally(entry.containerSize, entry.contentSize);
342
+ for (RnAppKitView *row in [content rnChildrenInPaintOrder]) {
343
+ const NSRect frame = row.frame;
344
+ boxes.push_back(ScrollChildBox{
345
+ .tag = static_cast<int>(row.rnTag),
346
+ .leading = horizontal ? frame.origin.x : frame.origin.y,
347
+ .length = horizontal ? frame.size.width : frame.size.height});
348
+ }
349
+ return boxes;
350
+ }
351
+
352
+ void AppKitScrollViewManager::prepareMaintainVisiblePosition() {
353
+ for (auto &[tag, entry] : entries_) {
354
+ entry.pinned = {};
355
+ if (!entry.maintainVisible.has_value()) {
356
+ continue;
357
+ }
358
+ const bool horizontal = scrollsHorizontally(entry.containerSize, entry.contentSize);
359
+ entry.pinned = firstVisibleChild(contentChildBoxes(entry),
360
+ entry.maintainVisible->minIndexForVisible,
361
+ horizontal ? entry.offsetX : entry.offsetY);
362
+ }
363
+ }
364
+
365
+ void AppKitScrollViewManager::adjustForMaintainVisiblePosition() {
366
+ for (auto &[tag, entry] : entries_) {
367
+ if (!entry.maintainVisible.has_value() || !entry.pinned.has) {
368
+ continue;
369
+ }
370
+ const bool horizontal = scrollsHorizontally(entry.containerSize, entry.contentSize);
371
+ const double offset = horizontal ? entry.offsetX : entry.offsetY;
372
+
373
+ // The same child, found again by tag: its *position in the list* is exactly
374
+ // what the mutation may have changed, so an index would find a different
375
+ // row. A child that is gone is not adjusted for, which is upstream's
376
+ // behaviour too -- it gives up rather than guessing.
377
+ const ScrollPinnedChild pinned = entry.pinned;
378
+ entry.pinned = {};
379
+ std::optional<double> leading;
380
+ for (const ScrollChildBox &box : contentChildBoxes(entry)) {
381
+ if (box.tag == pinned.tag) {
382
+ leading = box.leading;
383
+ break;
384
+ }
385
+ }
386
+ if (!leading.has_value()) {
387
+ continue;
388
+ }
389
+
390
+ const ScrollVisibleAdjustment adjustment = visiblePositionAdjustment(
391
+ pinned.leading, *leading, offset, entry.maintainVisible->autoscrollToTopThreshold);
392
+ if (!adjustment.move) {
393
+ continue;
394
+ }
395
+
396
+ if (horizontal) {
397
+ applyOffset(entry, offset + adjustment.delta, entry.offsetY, true);
398
+ } else {
399
+ applyOffset(entry, entry.offsetX, offset + adjustment.delta, true);
400
+ }
401
+
402
+ // And the start, animated, when the list was near enough to it to be
403
+ // following the new content rather than holding its place.
404
+ if (adjustment.autoscrollToStart) {
405
+ if (horizontal) {
406
+ scrollTowards(entry, 0.0, entry.offsetY, true);
407
+ } else {
408
+ scrollTowards(entry, entry.offsetX, 0.0, true);
409
+ }
410
+ }
411
+ }
412
+ }
413
+
215
414
  void AppKitScrollViewManager::remove(Tag tag) {
216
415
  const auto it = entries_.find(tag);
217
416
  if (it == entries_.end()) {
@@ -394,6 +593,17 @@ void AppKitScrollViewManager::updateIndicators(const Entry &entry) {
394
593
  horizontalInsets(entry.indicatorInset))
395
594
  : ScrollIndicator{};
396
595
 
596
+ const ScrollIndicatorColour thumb = scrollIndicatorColourFor(entry.indicatorStyle);
597
+ [entry.view setRnScrollIndicatorColourRed:thumb.red
598
+ green:thumb.green
599
+ blue:thumb.blue
600
+ alpha:thumb.alpha];
601
+ const basalt::ScrollIndicatorColour edge =
602
+ basalt::scrollIndicatorBorderColourFor(entry.indicatorStyle);
603
+ [entry.view setRnScrollIndicatorBorderColourRed:edge.red
604
+ green:edge.green
605
+ blue:edge.blue
606
+ alpha:edge.alpha];
397
607
  [entry.view setRnScrollIndicatorVerticalOffset:vertical.offset
398
608
  verticalLength:vertical.length
399
609
  horizontalOffset:horizontal.offset
@@ -32,6 +32,7 @@
32
32
  #include <optional>
33
33
  #include <string>
34
34
  #include <unordered_map>
35
+ #include <vector>
35
36
 
36
37
  namespace basalt {
37
38
 
@@ -51,6 +52,19 @@ class AppKitTextInputManager {
51
52
 
52
53
  void remove(facebook::react::Tag tag);
53
54
 
55
+ // Focuses whatever `autoFocus` asked for, once the transaction is on screen.
56
+ //
57
+ // It cannot be done where the prop is read. Props are applied by
58
+ // MountingWalk::create, immediately after `createView` and before any Insert,
59
+ // so the view has no superview and `view.window` is nil -- and a
60
+ // `makeFirstResponder:` to nil is a silent no-op. Worse, the flag that guarded
61
+ // it is `try_emplace`'s "first time this tag was seen", which is false by the
62
+ // time the Insert arrives, so it never ran twice either. Deferred to the end of
63
+ // the transaction, which is where resolveRefreshControls already lives for the
64
+ // same reason: Fabric inserts a subtree bottom-up, so even "just after
65
+ // insertChild" is too early.
66
+ void flushAutoFocus();
67
+
54
68
  // focus, blur, and setTextAndSelection. Returns false for anything else.
55
69
  bool dispatchCommand(facebook::react::Tag tag,
56
70
  const std::string &name,
@@ -59,7 +73,10 @@ class AppKitTextInputManager {
59
73
  // Called by the Objective-C delegate. Public because the trampoline has to
60
74
  // reach them; not part of anything a caller would use.
61
75
  void handleChanged(facebook::react::Tag tag);
62
- void handleSubmit(facebook::react::Tag tag);
76
+ // Return. What it does is `submitBehavior`'s to say: report a submit, report
77
+ // one and give up focus, or insert a newline. Returns true when the key was
78
+ // used up, which is what tells AppKit whether to insert the newline itself.
79
+ bool handleSubmit(facebook::react::Tag tag);
63
80
  void handleFocus(facebook::react::Tag tag);
64
81
  void handleBlur(facebook::react::Tag tag);
65
82
 
@@ -74,6 +91,11 @@ class AppKitTextInputManager {
74
91
  bool handleKeyDown(void *event);
75
92
 
76
93
  private:
94
+ // Set when a field is created with `autoFocus`, cleared by flushAutoFocus.
95
+ // A list rather than one tag because nothing stops two fields asking; the last
96
+ // one to be focused wins, which is what AppKit would do anyway.
97
+ std::vector<facebook::react::Tag> pendingAutoFocus_;
98
+
77
99
  struct Entry {
78
100
  // The selection as JavaScript last saw it, so one movement is one event.
79
101
  facebook::react::AttributedString::Range lastReportedSelection{0, 0};
@@ -94,9 +116,24 @@ class AppKitTextInputManager {
94
116
  bool applying{false};
95
117
 
96
118
  bool secure{false};
119
+ // `submitBehavior`, already resolved: React Native's `Default` means
120
+ // `newline` for a multiline field and `blurAndSubmit` for a single-line
121
+ // one, and `getNonDefaultSubmitBehavior()` is what says so. Read where
122
+ // Return arrives, which is a delegate callback on both peers.
123
+ facebook::react::SubmitBehavior submitBehavior{
124
+ facebook::react::SubmitBehavior::BlurAndSubmit};
125
+
97
126
  // A multiline field is an NSTextView rather than an NSTextField, so a
98
127
  // change here rebuilds the peer exactly as `secure` does.
99
128
  bool multiline{false};
129
+
130
+ // `clearTextOnFocus` and `selectTextOnFocus`: what happens when the field
131
+ // takes focus. Kept here because focus arrives long after the props do, and
132
+ // because the second has to be known in `flushAutoFocus` as well -- a field
133
+ // that asked to select its text must not have the selection collapsed
134
+ // behind it.
135
+ bool clearTextOnFocus{false};
136
+ bool selectTextOnFocus{false};
100
137
  std::string lastReportedText;
101
138
 
102
139
  // The last `text` prop actually seen, and whether one has been seen at all.