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.
Files changed (43) 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 +582 -12
  8. package/native/appkit/AppKitScrollView.h +48 -0
  9. package/native/appkit/AppKitScrollView.mm +210 -0
  10. package/native/appkit/AppKitSkiaPeer.h +15 -0
  11. package/native/appkit/AppKitTextInput.h +38 -1
  12. package/native/appkit/AppKitTextInput.mm +235 -8
  13. package/native/appkit/AppKitTextPeer.h +60 -0
  14. package/native/appkit/AppKitTextPeer.mm +307 -0
  15. package/native/appkit/AppKitTouchDispatcher.h +56 -0
  16. package/native/appkit/AppKitTouchDispatcher.mm +140 -0
  17. package/native/appkit/AppKitWindowControl.mm +38 -3
  18. package/native/appkit/ComponentRegistryAppKit.mm +28 -3
  19. package/native/appkit/CoreTextLayout.h +64 -2
  20. package/native/appkit/CoreTextLayout.mm +431 -71
  21. package/native/appkit/CoreTextLayoutManager.mm +57 -6
  22. package/native/appkit/FontRegistryCoreText.mm +6 -5
  23. package/native/appkit/PlatformServicesAppKit.mm +101 -20
  24. package/native/appkit/RnAppKitView.h +492 -0
  25. package/native/appkit/RnAppKitView.mm +2266 -23
  26. package/native/appkit/RnTextLayout.h +106 -0
  27. package/native/appkit/RnTextLayout.mm +321 -2
  28. package/native/appkit/main_appkit.mm +174 -3
  29. package/native/tests/test_appkit_accessibility.mm +289 -0
  30. package/native/tests/test_appkit_borders.mm +975 -0
  31. package/native/tests/test_appkit_fonts.mm +203 -0
  32. package/native/tests/test_appkit_image.mm +392 -17
  33. package/native/tests/test_appkit_input.mm +209 -0
  34. package/native/tests/test_appkit_mounting.mm +360 -0
  35. package/native/tests/test_appkit_scrollbar.mm +125 -0
  36. package/native/tests/test_appkit_scrollview.mm +371 -0
  37. package/native/tests/test_appkit_selection.mm +349 -0
  38. package/native/tests/test_appkit_services.mm +51 -1
  39. package/native/tests/test_appkit_text.mm +1628 -0
  40. package/native/tests/test_appkit_textinput.mm +819 -0
  41. package/native/tests/test_appkit_titlebar.mm +43 -0
  42. package/native/tests/test_appkit_view.mm +609 -0
  43. package/package.json +4 -3
@@ -10,6 +10,27 @@
10
10
  #ifdef __OBJC__
11
11
  #import <Cocoa/Cocoa.h>
12
12
 
13
+ // Annotated as a whole, the way RnAppKitView.h is: one `nullable` in a file
14
+ // turns on -Wnullability-completeness for every pointer in it, and the shadow
15
+ // colour below is genuinely optional.
16
+ NS_ASSUME_NONNULL_BEGIN
17
+
18
+ // A wavy underline, which Core Text has no pattern for.
19
+ //
20
+ // `NSUnderlineStyle` is a bitmask of a line style and a *pattern* -- dot, dash,
21
+ // dash-dot -- and there is no wavy member: the squiggle under a misspelling is
22
+ // drawn by the text system's spell checking rather than by an underline. So
23
+ // `textDecorationStyle: 'wavy'` came out as a straight line, which GTK draws
24
+ // correctly and this host did not.
25
+ //
26
+ // The attribute is left off a wavy range and this key carries the colour the
27
+ // line should be drawn in, which the layout strokes itself in
28
+ // `drawInContext:size:`. An attributed string taking keys nobody else knows is
29
+ // AppKit's own extension point, and it keeps the squiggle attached to exactly
30
+ // the characters that asked for it -- a nested `<Text>` with a wavy underline
31
+ // inside a plain one is two ranges and draws as two.
32
+ extern NSAttributedStringKey const RnWavyUnderlineAttributeName;
33
+
13
34
  @interface RnTextLayout : NSObject
14
35
 
15
36
  @property(nonatomic, readonly) NSAttributedString *attributedString;
@@ -18,6 +39,37 @@
18
39
  @property(nonatomic, readonly) CTLineTruncationType truncationType;
19
40
  @property(nonatomic, readonly) BOOL truncates;
20
41
 
42
+ // The paragraph's text shadow, from core/TextShadows.h: an offset in React
43
+ // Native's coordinates -- y down -- the gaussian's standard deviation, and a
44
+ // colour, or nil for no shadow.
45
+ //
46
+ // Core Text does not honour an `NSShadow` attribute the way AppKit's own text
47
+ // drawing does, so this is set on the context around the line drawing rather
48
+ // than carried in the attributed string. `standardDeviation` is what
49
+ // `CGContextSetShadowWithColor` calls blur and is what React Native hands over,
50
+ // which is why nothing here converts it.
51
+ // Whether the paragraph runs right to left, resolved.
52
+ //
53
+ // The paragraph style carries `baseWritingDirection`, and its default --
54
+ // `NSWritingDirectionNatural` -- means "ask the text", which only a frame
55
+ // resolves. This layer draws its own lines so that `numberOfLines` can be
56
+ // honoured, so no frame ever gets the chance: a paragraph of Hebrew with
57
+ // nothing set drew flush left. The answer comes from core/TextDirection.h on
58
+ // the React Native side, where the text is, and the default of NO is what a
59
+ // paragraph built by hand means -- the style's own direction still stands.
60
+ @property(nonatomic) BOOL rightToLeft;
61
+
62
+ // How far down its own box the paragraph sits: 0 for the top, 0.5 for the
63
+ // middle, 1 for the bottom, which is what `textAlignVertical` asks for and what
64
+ // `verticalAlign` becomes in React Native's own JavaScript. The offset it
65
+ // produces is core/TextVerticalAlign.h's, applied while drawing, because the
66
+ // box is only known then.
67
+ @property(nonatomic) CGFloat verticalFlush;
68
+
69
+ @property(nonatomic, strong, nullable) NSColor *shadowColor;
70
+ @property(nonatomic) CGSize shadowOffset;
71
+ @property(nonatomic) CGFloat shadowStandardDeviation;
72
+
21
73
  + (instancetype)layoutWithAttributedString:(NSAttributedString *)attributedString
22
74
  maximumNumberOfLines:(NSInteger)maximumNumberOfLines
23
75
  truncationType:(CTLineTruncationType)truncationType
@@ -31,8 +83,62 @@
31
83
  // places the two platforms differ in shape rather than in spelling.
32
84
  - (CGSize)sizeForWidth:(CGFloat)maxWidth;
33
85
 
86
+ // Where the character at `index` sits once the paragraph is broken at `maxWidth`,
87
+ // in paragraph coordinates with y growing downwards, which is what the layer
88
+ // above uses. `index` is a UTF-16 offset, the unit NSAttributedString counts in.
89
+ //
90
+ // For an inline view this is the box a run delegate reserved, so the origin is
91
+ // its top-left corner. CGRectNull when the index is past the end, or past a
92
+ // `numberOfLines` truncation, which is a real answer rather than a failure: the
93
+ // view is not on screen.
94
+ - (CGRect)frameForCharacterIndex:(NSUInteger)index width:(CGFloat)maxWidth;
95
+
96
+ // --- Selecting that text ------------------------------------------------------
97
+ //
98
+ // The range to draw as selected, in UTF-16 code units -- what
99
+ // `NSAttributedString` counts and what the hit test below answers. Length zero
100
+ // selects nothing, which is how a selection is cleared.
101
+ //
102
+ // The state machine above this -- what a press and a drag mean -- is
103
+ // core/TextSelection.h, shared with the other two hosts. What is here is the
104
+ // part that needs Core Text.
105
+ @property(nonatomic) NSRange selection;
106
+
107
+ // The character index nearest a point in the view's own coordinates, inside a
108
+ // box of `size` -- the same box `drawInContext:size:` is given, because the
109
+ // answer depends on all of it: the width breaks the lines, and the height is
110
+ // what `textAlignVertical` moved the paragraph inside.
111
+ //
112
+ // Nearest rather than inside: a point past the end of a line answers the end of
113
+ // that line, and a point above the first or below the last answers that line,
114
+ // which is what dragging off the edge of a paragraph has to mean.
115
+ - (NSUInteger)characterIndexAtPoint:(CGPoint)point size:(CGSize)size;
116
+
117
+ // The selected text, or nil when nothing is selected. What the clipboard takes:
118
+ // a substring rather than a pair of offsets.
119
+ - (nullable NSString *)selectedText;
120
+
121
+ // The character range of each line this paragraph breaks into at `maxWidth`,
122
+ // as NSValue-wrapped NSRanges, after `numberOfLines` and any truncation.
123
+ //
124
+ // For a screen reader, which reads a paragraph line by line and asks which
125
+ // line an index is on. The lines are the visual ones -- a wrapped sentence is
126
+ // several, which is what the person listening hears -- and they are Core
127
+ // Text's own, so they match what is drawn rather than approximating it.
128
+ - (NSArray<NSValue *> *)lineRangesForWidth:(CGFloat)maxWidth;
129
+
130
+ // How far down a box `size` the paragraph is drawn: 0 unless `verticalFlush`
131
+ // says otherwise, and the number the draw and the hit test both use.
132
+ //
133
+ // Public because an inline `<View>` has to move with the text it sits in: React
134
+ // Native places an attachment from the top of the content box and knows nothing
135
+ // about `textAlignVertical`.
136
+ - (CGFloat)verticalOffsetForSize:(CGSize)size;
137
+
34
138
  // Draws into `context`, top-left origin, in a box `size` wide and tall.
35
139
  - (void)drawInContext:(CGContextRef)context size:(CGSize)size;
36
140
 
37
141
  @end
142
+ NS_ASSUME_NONNULL_END
143
+
38
144
  #endif
@@ -1,5 +1,10 @@
1
1
  #import "RnTextLayout.h"
2
2
 
3
+ NSAttributedStringKey const RnWavyUnderlineAttributeName = @"RnWavyUnderline";
4
+
5
+ #include "TextHighlight.h"
6
+ #include "TextVerticalAlign.h"
7
+
3
8
  #include <cmath>
4
9
 
5
10
  @implementation RnTextLayout {
@@ -91,6 +96,25 @@
91
96
  return collected;
92
97
  }
93
98
 
99
+ // The lines as ranges, which is the one thing accessibility needs and drawing
100
+ // does not: a truncated last line reports the range it *shows*, so an index
101
+ // past the ellipsis belongs to no line and is clamped to the last one by the
102
+ // caller.
103
+ - (NSArray<NSValue *> *)lineRangesForWidth:(CGFloat)maxWidth {
104
+ NSMutableArray *lines = [NSMutableArray array];
105
+ [self linesForWidth:maxWidth outLines:lines];
106
+ NSMutableArray<NSValue *> *ranges = [NSMutableArray arrayWithCapacity:lines.count];
107
+ for (id line in lines) {
108
+ const CFRange range = CTLineGetStringRange((__bridge CTLineRef)line);
109
+ if (range.location == kCFNotFound) {
110
+ continue;
111
+ }
112
+ [ranges addObject:[NSValue valueWithRange:NSMakeRange((NSUInteger)range.location,
113
+ (NSUInteger)range.length)]];
114
+ }
115
+ return ranges;
116
+ }
117
+
94
118
  // Line metrics, summed. Not CTFramesetterSuggestFrameSizeWithConstraints: that
95
119
  // measures the whole text and cannot be told about a line limit, so a
96
120
  // numberOfLines={2} paragraph would be laid out two lines tall and measured
@@ -111,6 +135,58 @@
111
135
  return CGSizeMake(std::ceil(width), height);
112
136
  }
113
137
 
138
+ - (CGRect)frameForCharacterIndex:(NSUInteger)index width:(CGFloat)maxWidth {
139
+ NSMutableArray *lines = [NSMutableArray array];
140
+ [self linesForWidth:maxWidth outLines:lines];
141
+
142
+ // Walked the same way `sizeForWidth:` sums it, so a view's y agrees with the
143
+ // paragraph height the same layout reported. Duplicating the accumulation
144
+ // rather than sharing it would be the way these two drift apart.
145
+ CGFloat top = 0;
146
+ for (id item in lines) {
147
+ CTLineRef line = (__bridge CTLineRef)item;
148
+ CGFloat ascent = 0, descent = 0, leading = 0;
149
+ (void)CTLineGetTypographicBounds(line, &ascent, &descent, &leading);
150
+ const CGFloat lineHeight = std::ceil(ascent + descent + leading);
151
+
152
+ const CFRange range = CTLineGetStringRange(line);
153
+ const auto start = static_cast<NSUInteger>(range.location);
154
+ const auto length = static_cast<NSUInteger>(range.length);
155
+ if (index >= start && index < start + length) {
156
+ const CGFloat x = CTLineGetOffsetForStringIndex(line, static_cast<CFIndex>(index), nullptr);
157
+
158
+ // The run's own ascent and descent, not the line's: a short view on a line
159
+ // of tall text is its own height, and asking the line would give it the
160
+ // tallest thing on it. Found by the run whose range contains the index.
161
+ CGFloat runAscent = ascent;
162
+ CGFloat runDescent = descent;
163
+ CFArrayRef runs = CTLineGetGlyphRuns(line);
164
+ for (CFIndex r = 0; r < CFArrayGetCount(runs); r++) {
165
+ CTRunRef run = static_cast<CTRunRef>(CFArrayGetValueAtIndex(runs, r));
166
+ const CFRange runRange = CTRunGetStringRange(run);
167
+ const auto runStart = static_cast<NSUInteger>(runRange.location);
168
+ const auto runLength = static_cast<NSUInteger>(runRange.length);
169
+ if (index >= runStart && index < runStart + runLength) {
170
+ CTRunGetTypographicBounds(run, CFRangeMake(0, 0), &runAscent, &runDescent, nullptr);
171
+ break;
172
+ }
173
+ }
174
+
175
+ // Top-left, from a baseline-anchored box: the baseline sits `ascent` below
176
+ // the line's top, and the run rises `runAscent` above the baseline.
177
+ return CGRectMake(x,
178
+ top + (ascent - runAscent),
179
+ 0,
180
+ runAscent + runDescent);
181
+ }
182
+
183
+ top += lineHeight;
184
+ }
185
+
186
+ // Past the end, or dropped by a truncation.
187
+ return CGRectNull;
188
+ }
189
+
114
190
  // How far along the line the text sits: 0 left, 0.5 centred, 1 right.
115
191
  //
116
192
  // Taken from the first fragment, because React Native resolves alignment onto
@@ -131,9 +207,194 @@
131
207
  return 0.5;
132
208
  case NSTextAlignmentRight:
133
209
  return 1.0;
134
- default:
210
+ case NSTextAlignmentLeft:
211
+ // Explicitly left, which is not the same as natural: a right-to-left
212
+ // paragraph with `textAlign: 'left'` is flush left, and this used to fall
213
+ // through to the natural branch below and come out flush right.
135
214
  return 0;
215
+ default:
216
+ break;
136
217
  }
218
+
219
+ // What is left is `natural` and `justified`, and both mean the edge the
220
+ // paragraph *starts* from -- the relative alignments, `start` and `end`, were
221
+ // resolved into left or right before they got here; see
222
+ // core/TextAlignments.h.
223
+ //
224
+ // Which edge that is needs the paragraph's direction, and the style's own is
225
+ // only half of it: `NSWritingDirectionNatural` means "ask the text", which a
226
+ // frame would resolve and this does not use. `rightToLeft` is that answer,
227
+ // from core/TextDirection.h. Measured on both hosts and wrong on both at
228
+ // first: Pango flips a natural alignment only while it is deciding the
229
+ // direction itself, and Core Text only inside a frame. The two fixes are in
230
+ // different places and are the same decision. See PangoTextLayout.cpp.
231
+ if (_rightToLeft || style.baseWritingDirection == NSWritingDirectionRightToLeft) {
232
+ return 1.0;
233
+ }
234
+ return 0;
235
+ }
236
+
237
+ // `textAlignVertical`: where the paragraph sits in a box taller than it is. The
238
+ // height the text needs is the one `sizeForWidth:` sums, walked the same way
239
+ // here, so this asks the lines rather than measuring again.
240
+ //
241
+ // Its own method because the hit test needs the same number as the draw: a
242
+ // paragraph sitting at the bottom of its box is hit-tested there too.
243
+ - (CGFloat)verticalOffsetForSize:(CGSize)size lines:(NSArray *)lines {
244
+ if (self.verticalFlush <= 0) {
245
+ return 0;
246
+ }
247
+ CGFloat textHeight = 0;
248
+ for (id item in lines) {
249
+ CTLineRef line = (__bridge CTLineRef)item;
250
+ CGFloat ascent = 0, descent = 0, leading = 0;
251
+ (void)CTLineGetTypographicBounds(line, &ascent, &descent, &leading);
252
+ textHeight += std::ceil(ascent + descent + leading);
253
+ }
254
+ return (CGFloat)basalt::textVerticalOffset(size.height, textHeight, self.verticalFlush);
255
+ }
256
+
257
+ - (CGFloat)verticalOffsetForSize:(CGSize)size {
258
+ if (self.verticalFlush <= 0) {
259
+ // The common case, and worth answering without breaking the text into lines
260
+ // to do it: this is asked once per inline view per layout.
261
+ return 0;
262
+ }
263
+ NSMutableArray *lines = [NSMutableArray array];
264
+ [self linesForWidth:size.width outLines:lines];
265
+ return [self verticalOffsetForSize:size lines:lines];
266
+ }
267
+
268
+ - (NSUInteger)characterIndexAtPoint:(CGPoint)point size:(CGSize)size {
269
+ NSMutableArray *lines = [NSMutableArray array];
270
+ [self linesForWidth:size.width outLines:lines];
271
+ if (lines.count == 0) {
272
+ return 0;
273
+ }
274
+
275
+ // Which line the y falls in, walked the same way `sizeForWidth:` sums the
276
+ // heights so that a hit test and a measurement agree about where a line is.
277
+ // A point above the first line or below the last answers that line, which is
278
+ // what a drag off the top or the bottom of a paragraph means.
279
+ CGFloat top = [self verticalOffsetForSize:size lines:lines];
280
+ CTLineRef found = (__bridge CTLineRef)lines[0];
281
+ for (id item in lines) {
282
+ CTLineRef line = (__bridge CTLineRef)item;
283
+ CGFloat ascent = 0, descent = 0, leading = 0;
284
+ (void)CTLineGetTypographicBounds(line, &ascent, &descent, &leading);
285
+ const CGFloat height = std::ceil(ascent + descent + leading);
286
+ found = line;
287
+ if (point.y < top + height) {
288
+ break;
289
+ }
290
+ top += height;
291
+ }
292
+
293
+ // The x, in the line's own coordinates: a right-aligned or centred paragraph
294
+ // draws its lines offset, and the index at a point has to use the same
295
+ // offset the glyphs were drawn with.
296
+ const CGFloat flush = [self flushFactor];
297
+ const CGFloat lineX =
298
+ flush == 0 ? 0 : (CGFloat)CTLineGetPenOffsetForFlush(found, flush, size.width);
299
+ const CFIndex index =
300
+ CTLineGetStringIndexForPosition(found, CGPointMake(point.x - lineX, 0));
301
+ return index == kCFNotFound ? 0 : (NSUInteger)index;
302
+ }
303
+
304
+ - (nullable NSString *)selectedText {
305
+ if (_selection.length == 0 || _attributedString.length == 0) {
306
+ return nil;
307
+ }
308
+ // Clamped against the string rather than trusted: a paragraph can be rebuilt
309
+ // between a selection and a copy, and the old offsets would read past the end
310
+ // of a shorter one.
311
+ const NSUInteger length = _attributedString.length;
312
+ const NSUInteger start = MIN(_selection.location, length);
313
+ const NSUInteger end = MIN(start + _selection.length, length);
314
+ if (end <= start) {
315
+ return nil;
316
+ }
317
+ return [_attributedString.string substringWithRange:NSMakeRange(start, end - start)];
318
+ }
319
+
320
+ // One line's wavy underlines, stroked where the font says an underline goes.
321
+ //
322
+ // `baselineY` is this line's baseline in the context's current space, which
323
+ // the caller has already flipped back into Core Text's y-up orientation.
324
+ //
325
+ // The wave is a run of quadratic curves rather than a dash pattern, which is
326
+ // the one shape a stroke cannot express: amplitude and period are multiples of
327
+ // the line's own thickness, so a 40pt squiggle has the same proportions as a
328
+ // 12pt one. Browsers draw it at about this size.
329
+ - (void)drawWavyUnderlinesForLine:(CTLineRef)line
330
+ context:(CGContextRef)context
331
+ atX:(CGFloat)originX
332
+ y:(CGFloat)baselineY {
333
+ const CFRange lineRange = CTLineGetStringRange(line);
334
+ if (lineRange.location == kCFNotFound || lineRange.length <= 0) {
335
+ return;
336
+ }
337
+ const NSRange range = NSMakeRange((NSUInteger)lineRange.location, (NSUInteger)lineRange.length);
338
+ if (NSMaxRange(range) > _attributedString.length) {
339
+ return;
340
+ }
341
+
342
+ [_attributedString enumerateAttribute:RnWavyUnderlineAttributeName
343
+ inRange:range
344
+ options:0
345
+ usingBlock:^(id value, NSRange found, BOOL *stop) {
346
+ (void)stop;
347
+ NSColor *color = [value isKindOfClass:[NSColor class]] ? (NSColor *)value : nil;
348
+ if (color == nil || found.length == 0) {
349
+ return;
350
+ }
351
+
352
+ // The font of the run the wave is under, so the line sits where that
353
+ // font's underline would have and is as thick as it would have been.
354
+ NSFont *font = [self->_attributedString attribute:NSFontAttributeName
355
+ atIndex:found.location
356
+ effectiveRange:nullptr];
357
+ const CGFloat thickness =
358
+ MAX((CGFloat)1.0, font != nil ? CTFontGetUnderlineThickness((__bridge CTFontRef)font) : 1.0);
359
+ const CGFloat position =
360
+ font != nil ? CTFontGetUnderlinePosition((__bridge CTFontRef)font) : -thickness * 2;
361
+
362
+ const CGFloat startX =
363
+ originX + CTLineGetOffsetForStringIndex(line, (CFIndex)found.location, nullptr);
364
+ const CGFloat endX =
365
+ originX + CTLineGetOffsetForStringIndex(line, (CFIndex)NSMaxRange(found), nullptr);
366
+ const CGFloat from = MIN(startX, endX);
367
+ const CGFloat to = MAX(startX, endX);
368
+ if (to - from < 1.0) {
369
+ return;
370
+ }
371
+
372
+ // The same geometry the Win32 renderer draws, down to the number that
373
+ // matters: quadratics alternating above and below the line, a half wave
374
+ // every six thicknesses, and a control point *twice* the amplitude away
375
+ // because a quadratic only reaches halfway to it. That host's comment
376
+ // records what happens otherwise -- a control point at one thickness
377
+ // draws a wave of half that, which measures as an edge moving two pixels
378
+ // where three were asked for -- and this host walked into it as well.
379
+ const CGFloat wave = thickness * 6.0;
380
+ const CGFloat middle = baselineY + position;
381
+
382
+ CGContextSaveGState(context);
383
+ CGContextSetStrokeColorWithColor(context, color.CGColor);
384
+ CGContextSetLineWidth(context, thickness);
385
+ CGContextSetLineCap(context, kCGLineCapRound);
386
+ CGContextBeginPath(context);
387
+ CGContextMoveToPoint(context, from, middle);
388
+ bool up = true;
389
+ for (CGFloat at = from; at < to; at += wave) {
390
+ const CGFloat next = MIN(at + wave, to);
391
+ const CGFloat peak = middle + 2.0 * (up ? thickness : -thickness);
392
+ CGContextAddQuadCurveToPoint(context, (at + next) / 2.0, peak, next, middle);
393
+ up = !up;
394
+ }
395
+ CGContextStrokePath(context);
396
+ CGContextRestoreGState(context);
397
+ }];
137
398
  }
138
399
 
139
400
  - (void)drawInContext:(CGContextRef)context size:(CGSize)size {
@@ -150,7 +411,22 @@
150
411
  CGContextScaleCTM(context, 1, -1);
151
412
  CGContextSetTextMatrix(context, CGAffineTransformIdentity);
152
413
 
153
- CGFloat y = size.height;
414
+ // The text shadow, on the context: Core Text ignores an NSShadow attribute,
415
+ // and one shadow for the paragraph is what core/TextShadows.h resolves anyway.
416
+ //
417
+ // The offset's y is negated because the context has just been flipped back to
418
+ // Core Text's orientation, where y grows upward: React Native means a positive
419
+ // offset as *down* the screen, which is what the GTK side gets for free.
420
+ if (self.shadowColor != nil) {
421
+ CGContextSetShadowWithColor(context,
422
+ CGSizeMake(self.shadowOffset.width, -self.shadowOffset.height),
423
+ self.shadowStandardDeviation,
424
+ self.shadowColor.CGColor);
425
+ }
426
+
427
+ // Core Text's y grows upward and the context has just been flipped back into
428
+ // that, so moving the paragraph *down* the box means starting lower.
429
+ CGFloat y = size.height - [self verticalOffsetForSize:size lines:lines];
154
430
  for (id item in lines) {
155
431
  CTLineRef line = (__bridge CTLineRef)item;
156
432
  CGFloat ascent = 0, descent = 0, leading = 0;
@@ -158,6 +434,49 @@
158
434
 
159
435
  y -= std::ceil(ascent + descent + leading);
160
436
  const CGFloat x = flush == 0 ? 0 : (CGFloat)CTLineGetPenOffsetForFlush(line, flush, size.width);
437
+
438
+ // The selection, under this line's glyphs and before them: a wash rather
439
+ // than a solid fill, because nothing recolours the text and it has to stay
440
+ // legible through it. See core/TextHighlight.h for why the colour is this
441
+ // project's rather than the system's.
442
+ if (_selection.length > 0) {
443
+ const CFRange lineRange = CTLineGetStringRange(line);
444
+ const NSUInteger lineStart = (NSUInteger)lineRange.location;
445
+ const NSUInteger lineEnd = lineStart + (NSUInteger)lineRange.length;
446
+ const NSUInteger from = MAX(_selection.location, lineStart);
447
+ const NSUInteger to = MIN(_selection.location + _selection.length, lineEnd);
448
+ if (to > from) {
449
+ // Two offsets along the line, which is where a bidirectional line needs
450
+ // more than one rectangle -- a selection contiguous in the string is not
451
+ // contiguous on the line. Taking the span between the two offsets is one
452
+ // rectangle and is what iOS's own `UITextView` draws for the common
453
+ // case; the mixed-direction case is recorded in backlog/text.md.
454
+ const CGFloat startX = CTLineGetOffsetForStringIndex(line, (CFIndex)from, nullptr);
455
+ const CGFloat endX = CTLineGetOffsetForStringIndex(line, (CFIndex)to, nullptr);
456
+ const CGRect wash = CGRectMake(x + MIN(startX, endX),
457
+ y,
458
+ std::abs(endX - startX),
459
+ std::ceil(ascent + descent + leading));
460
+ CGContextSaveGState(context);
461
+ CGContextSetRGBFillColor(context,
462
+ basalt::kTextSelectionRed,
463
+ basalt::kTextSelectionGreen,
464
+ basalt::kTextSelectionBlue,
465
+ basalt::kTextSelectionAlpha);
466
+ CGContextFillRect(context, wash);
467
+ CGContextRestoreGState(context);
468
+ }
469
+ }
470
+
471
+ // The squiggle Core Text has no pattern for, under this line's glyphs and
472
+ // before them: an underline goes beneath the text, and a descender
473
+ // crossing it is drawn on top the way a straight underline's is. See
474
+ // RnWavyUnderlineAttributeName.
475
+ [self drawWavyUnderlinesForLine:line
476
+ context:context
477
+ atX:x
478
+ y:y + std::ceil(descent + leading)];
479
+
161
480
  CGContextSetTextPosition(context, x, y + std::ceil(descent + leading));
162
481
  CTLineDraw(line, context);
163
482
  }