swift 如何在NSTextView中提取行号和字符位置?

xyhw6mcr  于 2023-06-04  发布在  Swift
关注(0)|答案(1)|浏览(162)

构建一个显示文本编辑器的应用程序,将光标位置作为行和偏移量传达给用户会很好。这是执行此操作的示例方法。

/// 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)")
    }
}

根据我的研究,苹果并没有为此提供任何API,事实上这甚至不是Xcode编辑器的一个功能。最后,我需要为上面使用的每行开始建立一个字符位置数组。每当NSTextField中发生任何更改时,都必须更新此数组。因此,该列表的生成必须非常有效和快速。
我发现/组装了四种方法来生成行开始数组:

第一种方法

使用glyphslineFragmentRect的数量-这是目前最慢的实现

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
}

第二种方法

使用paragraphs数组作为单独的行长度-根据Apple的说法,可能不建议使用,因为它可能会产生大量的对象。这里很可能不是这种情况,因为我们只是阅读段落数组,我们没有对它进行任何修改。实际上几乎与最快的实现一样快。如果你使用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
}

第三种方法

使用enumerateLines-预期非常快,但实际上比lineStartsWithParagraphs慢近两倍,但相当迅速。

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
}

第四种方法

  • 使用Swift中的lineRange-Swift中最快也可能是最好的实现。不能在Objective-C中使用。使用起来有点复杂,例如NSTextView.selectedRange返回一个NSRange,因此必须转换为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
}

基准:
| 方法|使用Ventura的M2上的32000行NSTextView的时间|
| - -----|- -----|
| 1. lineStartsWithLayout| 1.452秒|
| 2. lineStartsWithParagrams| 0.020秒|
| 3. lineStartsBy枚举|0.065秒|
| 4. indexStartsByLineRange| 0.019秒|
我更喜欢indexStartsByLineRange,但我有兴趣听听其他的意见在Objective-C中,我会坚持lineStartsWithParagraphs中的算法,考虑到一些调用必须适应。

pb3s4cty

pb3s4cty1#

由Willeke的评论触发,我检查了不同的行号和光标位置计算的可能性,结果令我惊讶。

func lineNumberRegularExpression() -> (Int, Int) {
    let start = ProcessInfo.processInfo.systemUptime;
    let selectionRange: NSRange = selectedRange()
    let regex = try! NSRegularExpression(pattern: "\n", options: [])
    let lineNumber = regex.numberOfMatches(in: string, options: [], range: NSMakeRange(0, selectionRange.location)) + 1
    var column = 0
    if let stringIndexSelection = Range(selectionRange, in: string) {
        let lineRange = string.lineRange(for: stringIndexSelection)
        column = string.distance(from: lineRange.lowerBound, to: stringIndexSelection.upperBound)
    }
    print("Using RegEx     :\(ProcessInfo.processInfo.systemUptime-start) s")
    return (lineNumber, column)
}

func lineNumberScanner() -> (Int, Int) {
    let start = ProcessInfo.processInfo.systemUptime;
    let selectionRange: NSRange = selectedRange()
    let stringIndexSelection = Range(selectionRange, in: string)!
    let startOfString = string[..<stringIndexSelection.upperBound]
    let scanner = Scanner(string: String(startOfString))
    scanner.charactersToBeSkipped = nil
    var lineNumber = 0
    while (nil != scanner.scanUpToCharacters(from: CharacterSet.newlines) && !scanner.isAtEnd) {
        lineNumber += 1
        scanner.currentIndex = scanner.string.index(after: scanner.currentIndex)
    }
    let lineRange = string.lineRange(for: stringIndexSelection)
    let column = string.distance(from: lineRange.lowerBound, to: stringIndexSelection.upperBound)
    print("Using scanner   :\(ProcessInfo.processInfo.systemUptime-start) s")
    return (lineNumber, column)
}

func lineNumberComponents() -> (Int, Int) {
    let start = ProcessInfo.processInfo.systemUptime;
    let stringIndexSelection = Range(selectedRange(), in: string)!
    let startOfString = string[..<stringIndexSelection.upperBound]
    var lineNumber = startOfString.components(separatedBy: "\n").count
    let lineRange = string.lineRange(for: stringIndexSelection)
    let column = string.distance(from: lineRange.lowerBound, to: stringIndexSelection.upperBound)
    print("Using components:\(ProcessInfo.processInfo.systemUptime-start) s")
    return (lineNumber, column)
}

func lineNumberEnumerate() -> (Int, Int) {
    let start = ProcessInfo.processInfo.systemUptime;

    let stringIndexSelection = Range(selectedRange(), in: string)!
    let startOfString = string[..<stringIndexSelection.upperBound]
    var lineNumber = 0
    startOfString.enumerateLines { (startOfString, _) in
        lineNumber += 1
    }

    let lineRange = string.lineRange(for: stringIndexSelection)
    let column = string.distance(from: lineRange.lowerBound, to: stringIndexSelection.upperBound)
    if 0 == column {
        lineNumber += 1
    }
    print("Using enumerate :\(ProcessInfo.processInfo.systemUptime-start) s")
    return (lineNumber, column)

}

func lineNumberReduce() -> (Int, Int) {
    let start = ProcessInfo.processInfo.systemUptime;

    let stringIndexSelection = Range(selectedRange(), in: string)!
    let startOfString = string[string.startIndex..<stringIndexSelection.upperBound]
    let lineNumber = startOfString.reduce(into: 1) { (counts, letter) in
        if "\n" == letter {
            counts += 1
        }
    }

    let lineRange = string.lineRange(for: stringIndexSelection)
    let column = string.distance(from: lineRange.lowerBound, to: stringIndexSelection.upperBound)
    print("Using reduce    :\(ProcessInfo.processInfo.systemUptime-start) s")
    return (lineNumber, column)

}

请注意这些方法之间的微小差异,但这是获得相同结果的唯一方法,除了使用reduce的方法有时会对某些文本产生太小的行号。奇怪的是,使用RegularExpression是最快的。对于32000行的文本,它总是在10 ms以下。
请注意不要使用.newline表示“\n”,因为这样会使“\a”和“\n”的行数增加一倍。
| 方法|基准|评论|
| - -----|- -----|- -----|
| RegEx| 0.006秒||
| 扫描器|0.036秒||
| 组件|0.038秒||
| 列举|0.028秒||
| 减少|0.132秒|故障|
所以对我来说,答案是,使用正则表达式,这是如此之快,以至于可能不需要保持一个行开始数组。

相关问题