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