/
LayoutManager.swift
405 lines (282 loc) · 16.3 KB
/
LayoutManager.swift
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
//
// LayoutManager.swift
//
// CotEditor
// https://coteditor.com
//
// Created by nakamuxu on 2005-01-10.
//
// ---------------------------------------------------------------------------
//
// © 2004-2007 nakamuxu
// © 2014-2020 1024jp
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
import Combine
import Cocoa
final class LayoutManager: NSLayoutManager, InvisibleDrawing, ValidationIgnorable, LineRangeCacheable {
// MARK: Protocol Properties
var showsControls = false
var invisiblesDefaultsObserver: AnyCancellable?
var ignoresDisplayValidation = false
var string: NSString { self.attributedString().string as NSString }
var lineRangeCache = LineRangeCache()
// MARK: Public Properties
var usesAntialias = true
var textFont: NSFont = .systemFont(ofSize: 0) {
// store text font to avoid the issue where the line height can be inconsistent by using a fallback font
// -> DO NOT use `self.firstTextView?.font`, because when the specified font doesn't support
// the first character of the text view content, it returns a fallback font for the first one.
didSet {
// cache metric values
self.defaultLineHeight = self.defaultLineHeight(for: textFont)
self.defaultBaselineOffset = self.defaultBaselineOffset(for: textFont)
self.boundingBoxForControlGlyph = self.boundingBoxForControlGlyph(for: textFont)
self.spaceWidth = textFont.width(of: " ")
}
}
var showsInvisibles = false {
didSet {
guard showsInvisibles != oldValue else { return }
self.invalidateInvisibleDisplay()
}
}
var invisiblesColor: NSColor = .disabledControlTextColor
var showsIndentGuides = false
var tabWidth = 0
private(set) var spaceWidth: CGFloat = 0
// MARK: Private Properties
private var defaultLineHeight: CGFloat = 1.0
private var defaultBaselineOffset: CGFloat = 0
private var boundingBoxForControlGlyph: NSRect = .zero
private var indentGuideObserver: AnyCancellable?
private static let unemphasizedSelectedContentBackgroundColor: NSColor = (ProcessInfo().operatingSystemVersion.majorVersion < 11)
? .secondarySelectedControlColor
: .unemphasizedSelectedContentBackgroundColor
// MARK: -
// MARK: Lifecycle
override init() {
super.init()
self.indentGuideObserver = UserDefaults.standard.publisher(for: .showIndentGuides)
.sink { [weak self] _ in
guard let self = self, self.showsInvisibles else { return }
self.invalidateDisplay(forCharacterRange: self.attributedString().range)
}
self.delegate = self
}
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
// MARK: Layout Manager Methods
/// adjust rect of last empty line
override func setExtraLineFragmentRect(_ fragmentRect: NSRect, usedRect: NSRect, textContainer container: NSTextContainer) {
// -> The height of the extra line fragment should be the same as other normal fragments that are likewise customized in the delegate.
var fragmentRect = fragmentRect
fragmentRect.size.height = self.lineHeight
var usedRect = usedRect
usedRect.size.height = self.lineHeight
super.setExtraLineFragmentRect(fragmentRect, usedRect: usedRect, textContainer: container)
}
/// draw glyphs
override func drawGlyphs(forGlyphRange glyphsToShow: NSRange, at origin: NSPoint) {
NSGraphicsContext.saveGraphicsState()
if NSGraphicsContext.currentContextDrawingToScreen() {
NSGraphicsContext.current?.shouldAntialias = self.usesAntialias
}
if self.showsIndentGuides {
self.drawIndentGuides(forGlyphRange: glyphsToShow, at: origin, color: self.invisiblesColor, tabWidth: self.tabWidth)
}
if self.showsInvisibles {
self.drawInvisibles(forGlyphRange: glyphsToShow, at: origin, baselineOffset: self.baselineOffset(for: .horizontal), color: self.invisiblesColor, types: UserDefaults.standard.showsInvisible)
}
super.drawGlyphs(forGlyphRange: glyphsToShow, at: origin)
NSGraphicsContext.restoreGraphicsState()
}
/// fill background rectangles with a color
override func fillBackgroundRectArray(_ rectArray: UnsafePointer<NSRect>, count rectCount: Int, forCharacterRange charRange: NSRange, color: NSColor) {
// modify selected highlight color when the window is inactive
// -> Otherwise, `.unemphasizedSelectedContentBackgroundColor` will be used forcibly and text becomes unreadable
// when the window appearance and theme are inconsistent.
if color == Self.unemphasizedSelectedContentBackgroundColor, // check if inactive
let textContainer = self.textContainer(forGlyphAt: self.glyphIndexForCharacter(at: charRange.location),
effectiveRange: nil, withoutAdditionalLayout: true),
let theme = (textContainer.textView as? Themable)?.theme,
let secondarySelectionColor = theme.secondarySelectionColor
{
secondarySelectionColor.setFill()
}
super.fillBackgroundRectArray(rectArray, count: rectCount, forCharacterRange: charRange, color: color)
}
/// invalidate display for the given character range
override func invalidateDisplay(forCharacterRange charRange: NSRange) {
// ignore display validation during applying temporary attributes continuously
// -> See `SyntaxParser.apply(highlights:range:)` for the usage of this option. (2018-12)
if self.ignoresDisplayValidation { return }
super.invalidateDisplay(forCharacterRange: charRange)
}
override func setGlyphs(_ glyphs: UnsafePointer<CGGlyph>, properties props: UnsafePointer<NSLayoutManager.GlyphProperty>, characterIndexes charIndexes: UnsafePointer<Int>, font aFont: NSFont, forGlyphRange glyphRange: NSRange) {
// fix the width of whitespaces when the base font is fixed pitch.
let newProps = UnsafeMutablePointer(mutating: props)
if self.textFont.isFixedPitch {
for index in 0..<glyphRange.length {
newProps[index].subtract(.elastic)
}
}
super.setGlyphs(glyphs, properties: newProps, characterIndexes: charIndexes, font: aFont, forGlyphRange: glyphRange)
}
override func processEditing(for textStorage: NSTextStorage, edited editMask: NSTextStorageEditActions, range newCharRange: NSRange, changeInLength delta: Int, invalidatedRange invalidatedCharRange: NSRange) {
if editMask.contains(.editedCharacters) {
self.invalidateLineRanges(in: newCharRange, changeInLength: delta)
}
super.processEditing(for: textStorage, edited: editMask, range: newCharRange, changeInLength: delta, invalidatedRange: invalidatedCharRange)
}
// MARK: Public Methods
/// Fixed line height to avoid having different line height by composite font.
var lineHeight: CGFloat {
let multiple = self.firstTextView?.defaultParagraphStyle?.lineHeightMultiple ?? 1.0
return multiple * self.defaultLineHeight
}
/// Fixed baseline offset to place glyphs vertically in the middle of a line.
///
/// - Parameter layoutOrientation: The text layout orientation.
/// - Returns: The baseline offset.
func baselineOffset(for layoutOrientation: TextLayoutOrientation) -> CGFloat {
switch layoutOrientation {
case .vertical:
return self.lineHeight / 2
default:
// remove the space above to make glyphs visually center
let diff = self.textFont.ascender - self.textFont.capHeight
return (self.lineHeight + self.defaultBaselineOffset - diff) / 2
}
}
}
extension LayoutManager: NSLayoutManagerDelegate {
/// adjust line height to be all the same
func layoutManager(_ layoutManager: NSLayoutManager, shouldSetLineFragmentRect lineFragmentRect: UnsafeMutablePointer<NSRect>, lineFragmentUsedRect: UnsafeMutablePointer<NSRect>, baselineOffset: UnsafeMutablePointer<CGFloat>, in textContainer: NSTextContainer, forGlyphRange glyphRange: NSRange) -> Bool {
// avoid inconsistent line height by a composite font
// -> The line height by normal input keeps consistant when overriding the related methods in NSLayoutManager.
// but then, the drawing won't be update properly when the font or line hight is changed.
// -> NSParagraphStyle's `.lineheightMultiple` can also control the line height,
// but it causes an issue when the first character of the string uses a fallback font.
lineFragmentRect.pointee.size.height = self.lineHeight
lineFragmentUsedRect.pointee.size.height = self.lineHeight
// vertically center the glyphs in the line fragment
baselineOffset.pointee = self.baselineOffset(for: textContainer.layoutOrientation)
return true
}
/// treat control characers as whitespace to draw replacement glyphs
func layoutManager(_ layoutManager: NSLayoutManager, shouldUse action: NSLayoutManager.ControlCharacterAction, forControlCharacterAt charIndex: Int) -> NSLayoutManager.ControlCharacterAction {
// -> Then, the glyph width can be modified in `layoutManager(_:boundingBoxForControlGlyphAt:...)`.
return self.showsControlCharacter(at: charIndex, proposedAction: action) ? .whitespace : action
}
/// make a blank space to draw the replacement glyph in `drawGlyphs(forGlyphRange:at:)` later
func layoutManager(_ layoutManager: NSLayoutManager, boundingBoxForControlGlyphAt glyphIndex: Int, for textContainer: NSTextContainer, proposedLineFragment proposedRect: NSRect, glyphPosition: NSPoint, characterIndex charIndex: Int) -> NSRect {
return self.boundingBoxForControlGlyph
}
/// avoid soft wrapping just after indent
func layoutManager(_ layoutManager: NSLayoutManager, shouldBreakLineByWordBeforeCharacterAt charIndex: Int) -> Bool {
// avoid creating CharacterSet every time
struct NonIndent { static let characterSet = CharacterSet(charactersIn: " \t").inverted }
// check if the character is the first non-whitespace character after indent
let lineStartIndex = self.lineStartIndex(at: charIndex)
let range = NSRange(location: lineStartIndex, length: charIndex - lineStartIndex)
guard !range.isEmpty else { return true }
return self.string.rangeOfCharacter(from: NonIndent.characterSet, range: range) != .notFound
}
/// apply sytax highlighing on printing also
func layoutManager(_ layoutManager: NSLayoutManager, shouldUseTemporaryAttributes attrs: [NSAttributedString.Key: Any] = [:], forDrawingToScreen toScreen: Bool, atCharacterIndex charIndex: Int, effectiveRange effectiveCharRange: NSRangePointer?) -> [NSAttributedString.Key: Any]? {
return attrs
}
}
// MARK: Private Extension
private extension NSLayoutManager {
/// Draw indent guides at every given indent width.
///
/// - Parameters:
/// - glyphsToShow: The range of glyphs that are drawn.
/// - origin: The position of the text container in the coordinate system of the currently focused view.
/// - color: The color of guides.
/// - tabWidth: The number of spaces for an indent.
func drawIndentGuides(forGlyphRange glyphsToShow: NSRange, at origin: NSPoint, color: NSColor, tabWidth: Int) {
guard tabWidth > 0 else { return assertionFailure() }
// calculate characterRange to seek
let string = self.attributedString().string as NSString
let charactersToShow = self.characterRange(forGlyphRange: glyphsToShow, actualGlyphRange: nil)
let lineStartIndex = string.lineStartIndex(at: charactersToShow.location)
let characterRange = NSRange(location: lineStartIndex, length: charactersToShow.upperBound - lineStartIndex)
// find indent indexes
var indentIndexes: [(lineRange: NSRange, indexes: [Int])] = []
string.enumerateSubstrings(in: characterRange, options: [.byLines, .substringNotRequired]) { (_, range, _, _) in
var indexes: [Int] = []
var spaceCount = 0
loop: for characterIndex in range.lowerBound..<range.upperBound {
let isIndentLevel = spaceCount.isMultiple(of: tabWidth) && spaceCount > 0
switch string.character(at: characterIndex) {
case 0x0020: // space
spaceCount += 1
case 0x0009: // tab
spaceCount += tabWidth - (spaceCount % tabWidth)
default:
break loop
}
if isIndentLevel {
indexes.append(characterIndex)
}
}
guard !indexes.isEmpty else { return }
indentIndexes.append((range, indexes))
}
guard !indentIndexes.isEmpty else { return }
NSGraphicsContext.saveGraphicsState()
color.set()
let lineWidth: CGFloat = 0.5
let scaleFactor = NSGraphicsContext.current?.cgContext.ctm.a ?? 1
// draw guides logical line by logical line
for (lineRange, indexes) in indentIndexes {
// calculate vertical area to draw lines
let glyphIndex = self.glyphIndexForCharacter(at: lineRange.location)
var effectiveRange: NSRange = .notFound
let lineFragment = self.lineFragmentRect(forGlyphAt: glyphIndex, effectiveRange: &effectiveRange)
let guideLength: CGFloat = {
guard !effectiveRange.contains(lineRange.upperBound - 1) else { return lineFragment.height }
let lastGlyphIndex = self.glyphIndexForCharacter(at: lineRange.upperBound - 1)
let lastLineFragment = self.lineFragmentRect(forGlyphAt: lastGlyphIndex, effectiveRange: nil)
// check whether hanging indent is enabled
guard lastLineFragment.minX != lineFragment.minX else { return lineFragment.height }
return lastLineFragment.maxY - lineFragment.minY
}()
let guideSize = NSSize(width: lineWidth, height: guideLength)
// draw lines
for index in indexes {
let glyphIndex = self.glyphIndexForCharacter(at: index)
let glyphLocation = self.location(forGlyphAt: glyphIndex)
let guideOrigin = lineFragment.origin.offset(by: origin).offsetBy(dx: glyphLocation.x).aligned(scale: scaleFactor)
let guideRect = NSRect(origin: guideOrigin, size: guideSize)
guideRect.fill()
}
}
NSGraphicsContext.restoreGraphicsState()
}
}
private extension CGPoint {
/// Make the point pixel-perfect with the desired scale.
///
/// - Parameter scale: The scale factor in which the receiver to be pixel-perfect.
/// - Returns: An adjusted point.
func aligned(scale: CGFloat = 1) -> Self {
return Self(x: round(self.x * scale) / scale,
y: round(self.y * scale) / scale)
}
}