Building an app that displays a text editor it would be nice to communicate to the user the cursor position as line and offset. This is an example method doing this.
/// Find row and column of cursor position
/// Checks indexStarts for the index of the start larger than the selected position and
/// calculates the distance between cursor position and the previous line start.
func setColumnAndRow() {
// Convert NSRange to Range<String.Index>
let selectionRange = Range(selectedRange, in: string)!
// Retrieve the first start line greater than the cursor position
if let nextIndex = indexStarts.firstIndex(
where: {$0 > selectionRange.lowerBound}
) {
// The line with the cursor was one before that
let lineIndex = nextIndex - 1
// Use the <String.Index>.distance to determine the column position
let distance = string.distance(from: indexStarts[lineIndex]
, to: selectionRange.lowerBound
)
print("column: \(distance), row: \(lineIndex)")
} else {
print("column: 0, row: \(indexStarts.count-1)")
}
}
According to my research Apple does not offer any API for this purpose, in fact this is not even a feature of the Xcode editor. I ended up that I need to build up an array of the character position for each line start as used above. This array must be updated every time anything changes in the NSTextField. Therefore the generation of this list must be very effective and fast.
I found/assembled four methods to generate the line start array:
1st method
Uses number of glyphs and lineFragmentRect - This is by far the slowest implementation
func lineStartsWithLayout() -> [Int] {
// about 100 times slower than below
let start = ProcessInfo.processInfo.systemUptime
var lineStarts:[Int] = []
let layoutManager = layoutManager!
let numberOfGlyphs = layoutManager.numberOfGlyphs
var lineRange: NSRange = NSRange()
var indexOfGlyph: Int = 0
lineStarts.append(indexOfGlyph)
while indexOfGlyph < numberOfGlyphs {
layoutManager.lineFragmentRect(
forGlyphAt: indexOfGlyph
, effectiveRange: &lineRange
, withoutAdditionalLayout: false
)
indexOfGlyph = NSMaxRange(lineRange)
lineStarts.append(indexOfGlyph)
}
lineStarts.append(Int.max)
Logger.write("\(ProcessInfo.processInfo.systemUptime-start) s")
return lineStarts
}
2nd method
Uses the paragraphs array for the individual line length - According to Apple may be not recommended as it might produce plenty of objects. Here this very likely is not the case as we are just reading the paragraph array and we don't apply any modification to it. In effect nearly as fast as the fastest implementation. Therefore my recommendation if you use Objective-C.
func lineStartsWithParagraphs() -> [Int] {
// about 100 times faster than above
let start = ProcessInfo.processInfo.systemUptime;
var lineStarts:[Int] = []
var lineStart = 0
lineStarts = []
lineStarts.append(lineStart)
for p in textStorage?.paragraphs ?? [] {
lineStart += p.length
lineStarts.append(lineStart)
}
lineStarts.append(Int.max)
Logger.write("\(ProcessInfo.processInfo.systemUptime-start) s")
return lineStarts
}
3rd method
Uses enumerateLines - Expected to be very fast, but in effect nearly twice as slow than lineStartsWithParagraphs, but quite Swifty.
func lineStartsByEnumerating() -> [Int] {
let start = ProcessInfo.processInfo.systemUptime;
var lineStarts:[Int] = []
var lineStart = 0
lineStarts = []
lineStarts.append(lineStart)
string.enumerateLines {
line, stop in
lineStart += line.count
lineStarts.append(lineStart)
}
lineStarts.append(Int.max)
Logger.write("\(ProcessInfo.processInfo.systemUptime-start) s")
return lineStarts
}
4th method
Uses lineRange from Swift - Fastest and probably best implementation for Swift. Can't be used in Objective-C. Little bit more complicated to use as for example NSTextView.selectedRange returns an NSRange and therefore must be converted to Range<String.Index>.
func indexStartsByLineRange() -> [String.Index] {
/*
// Convert Range<String.Index> to NSRange:
let range = s[s.startIndex..<s.endIndex]
let nsRange = NSRange(range, in: s)
// Convert NSRange to Range<String.Index>:
let nsRange = NSMakeRange(0, 4)
let range = Range(nsRange, in: s)
*/
let start = ProcessInfo.processInfo.systemUptime;
var indexStarts:[String.Index] = []
var index = string.startIndex
indexStarts.append(index)
while index != string.endIndex {
let range = string.lineRange(for: index..<index)
index = range.upperBound
indexStarts.append(index)
}
Logger.write("\(ProcessInfo.processInfo.systemUptime-start) s")
return indexStarts
}
Benchmark:
Method | Time for NSTextView with 32000 lines on M2 with Ventura |
---|---|
1. lineStartsWithLayout | 1.452 s |
2. lineStartsWithParagraphs | 0.020 s |
3. lineStartsByEnumerating | 0.065 s |
4. indexStartsByLineRange | 0.019 s |
I would prefer indexStartsByLineRange, but I am interested to hear other opinons In Objective-C I would stick to the algo in lineStartsWithParagraphs, taking into account some calls must be adapted.