You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

使用NSTextSelection表示插入点触发崩溃的技术咨询

TextKit 2 设置文本选择到文档末尾插入点时崩溃的问题

根据Apple官方文档,NSTextLayoutManager.textSelections的每个选定点代表插入点(即屏幕上的文本光标位置,可位于文档范围末尾)。但在非空文档中设置该属性时触发崩溃:例如文档内容为"T",尝试将插入点设置在第2列(非零索引,对应文档末尾),此时设置2..2的选择范围会导致TextKit计算位置偏移时因to字段不符合预期而崩溃。

请问:

  • textSelection的范围是否必须映射到文档有效范围?
  • 插入点是否需要单独追踪?

最小复现代码

CustomTextLocation.swift

import AppKit

class CustomTextLocation: NSObject {
    var column: Int = 1 // 非零索引

    init(column: Int) {
        self.column = column
    }

    override public var description: String {
        "\(column)"
    }
}

extension CustomTextLocation: NSTextLocation {
    func compare(_ location: NSTextLocation) -> ComparisonResult {
        guard let location = location as? CustomTextLocation else {
            fatalError("Expected CustomTextLocation")
        }

        if column < location.column {
            return .orderedAscending
        } else if column == location.column {
            return .orderedSame
        } else {
            return .orderedDescending
        }
    }

    override func isEqual(_ object: Any?) -> Bool {
        guard let location = object as? CustomTextLocation else {
            return false
        }

        return self.column == location.column
    }
}

DocumentModel.swift

import AppKit

class DocumentModel: NSTextContentManager {
    var layoutManager: NSTextLayoutManager!
    var data = "T"

    override var documentRange: NSTextRange {
        let start = CustomTextLocation(column: 1)
        let end = CustomTextLocation(column: data.utf8.count + 1)

        return NSTextRange(location: start, end: end)!
    }

    func attach(textLayoutManager: NSTextLayoutManager) {
        self.layoutManager = textLayoutManager
        super.addTextLayoutManager(layoutManager)
    }

    // MARK: NSTextContentManager 重写
    override func textElements(for range: NSTextRange) -> [NSTextElement] {
        let textElement = NSTextElement(textContentManager: self)
        textElement.elementRange = documentRange
        return [textElement]
    }

    // MARK: NSTextElementProvider 重写
    override func enumerateTextElements(from textLocation: NSTextLocation?, options: NSTextContentManager.EnumerationOptions = [], using block: (NSTextElement) -> Bool) -> NSTextLocation? {
        guard let from = textLocation as? CustomTextLocation else {
            return nil
        }

        let textElements = textElements(for: NSTextRange(location: from, end: documentRange.endLocation)!)
        for textElement in textElements {
            let _ = block(textElement)
        }

        return documentRange.endLocation
    }

    override func location(_ location: NSTextLocation, offsetBy offset: Int) -> NSTextLocation? {
        guard let location = location as? CustomTextLocation else {
            fatalError("Expected CustomTextLocation")
        }

        return CustomTextLocation(column: location.column + offset)
    }
    
    override func offset(from: NSTextLocation, to: NSTextLocation) -> Int {
        guard let from = from as? CustomTextLocation,
              let to = to as? CustomTextLocation else {
            fatalError("Expected CustomTextLocation")
        }

        print("offset from: \(String(describing: from)), to: \(String(describing: to))")
        return to.column - from.column
    }
}

ViewController.swift

class ViewController: NSViewController {
    var layoutManager: NSTextLayoutManager!
    var textContainer: NSTextContainer!
    var documentModel: DocumentModel!

    override func viewDidLoad() {
        super.viewDidLoad()

        let textContainer = NSTextContainer(size: NSSize(width: 400, height: 400))
        self.textContainer = textContainer

        let layoutManager = NSTextLayoutManager()
        layoutManager.textContainer = textContainer
        self.layoutManager = layoutManager

        self.documentModel = DocumentModel()
        documentModel.attach(textLayoutManager: layoutManager)

        layoutManager.enumerateTextLayoutFragments(from: CustomTextLocation(column: 1), options: [.reverse, .ensuresExtraLineFragment, .ensuresLayout]) { fragment in
            print("\(fragment)")
            return false
        }

        let selectionLocation = CustomTextLocation(column: 2)
        // 以下代码触发崩溃
        layoutManager.textSelections = [NSTextSelection(selectionLocation, affinity: .downstream)]
    }
}

崩溃信息

TextLocationCrash/DocumentModel.swift:60: Fatal error: Expected CustomTextLocation

问题解答

核心结论

  1. textSelection范围必须落在文档有效范围内:包括文档末尾的插入点(即documentRange的endLocation),这个位置是合法的,不需要单独排除。
  2. 插入点无需单独追踪:只要它是documentRange内的合法NSTextLocation,TextKit就能正确处理。

崩溃原因分析

崩溃发生在offset(from:to:)方法中,因为TextKit内部处理选择逻辑时,可能会传入非CustomTextLocation的NSTextLocation实例(比如系统内置的NSNotFoundLocation),而你的代码直接通过fatalError终止程序,导致崩溃。

另外,你的enumerateTextElements方法在传入textLocation为nil时直接返回nil,不符合TextKit的预期——当from参数为nil时,应该从文档起始位置开始枚举文本元素。

修复方案

1. 完善offset(from:to:)方法的容错处理

不要直接触发fatalError,而是处理非CustomTextLocation的情况:

override func offset(from: NSTextLocation, to: NSTextLocation) -> Int {
    guard let fromLoc = from as? CustomTextLocation else {
        return 0
    }
    
    if to == NSNotFoundLocation {
        // 处理系统内置的未找到位置,返回合理值
        return 0
    }
    
    guard let toLoc = to as? CustomTextLocation else {
        return 0
    }

    print("offset from: \(fromLoc), to: \(toLoc)")
    return toLoc.column - fromLoc.column
}

2. 修复enumerateTextElements的nil处理逻辑

当传入的textLocation为nil时,从文档起始位置开始枚举:

override func enumerateTextElements(from textLocation: NSTextLocation?, options: NSTextContentManager.EnumerationOptions = [], using block: (NSTextElement) -> Bool) -> NSTextLocation? {
    let startLocation: CustomTextLocation
    if let from = textLocation as? CustomTextLocation {
        startLocation = from
    } else {
        // 当传入nil时,从文档起始位置开始
        startLocation = documentRange.startLocation as! CustomTextLocation
    }

    let textElements = textElements(for: NSTextRange(location: startLocation, end: documentRange.endLocation)!)
    for textElement in textElements {
        if block(textElement) {
            // 如果block返回true,提前终止枚举
            return textElement.elementRange?.endLocation
        }
    }

    return documentRange.endLocation
}

3. 完善CustomTextLocation的NSObject方法实现

重写hash方法,确保对象比较的一致性:

extension CustomTextLocation {
    override var hash: Int {
        column.hash
    }
}

4. 可选:优化location(_:offsetBy:)的边界检查

确保偏移后的位置不会超出文档范围:

override func location(_ location: NSTextLocation, offsetBy offset: Int) -> NSTextLocation? {
    guard let location = location as? CustomTextLocation else {
        return nil
    }
    
    let newColumn = location.column + offset
    let docStart = (documentRange.startLocation as! CustomTextLocation).column
    let docEnd = (documentRange.endLocation as! CustomTextLocation).column
    
    // 确保位置在文档范围内
    let clampedColumn = max(docStart, min(newColumn, docEnd))
    return CustomTextLocation(column: clampedColumn)
}

内容的提问来源于stack exchange,提问作者georgemp

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.04 18:11:01