子类化NSTextStorage中断列表编辑

时间:2018-11-21 11:08:38

标签: cocoa nstextview textkit nstextstorage

我有一个带有标准NSTextView的基本Mac应用程序。我正在尝试实现和使用NSTextStorage的子类,但是即使是非常基本的实现也会破坏列表编辑行为:

  1. 我添加了包含两个项目的项目符号列表
  2. 我将该列表复制并粘贴到文档中
  3. 在粘贴的列表中按Enter键会中断最后一个列表项的格式。

这是一个快速视频:

NSTextStorage list issue

两个问题:

  1. 粘贴列表的项目符号点使用较小的字体大小
  2. 第二个列表项中断第三个项目后按Enter键

当我不替换文本存储时,这很好。

这是我的代码:

ViewController.swift

@IBOutlet var textView:NSTextView!

override func viewDidLoad() {
   [...]
   textView.layoutManager?.replaceTextStorage(TestTextStorage())
}

TestTextStorage.swift

class TestTextStorage: NSTextStorage {

    let backingStore = NSMutableAttributedString()

    override var string: String {
        return backingStore.string
    }

    override func attributes(at location: Int, effectiveRange range: NSRangePointer?) -> [NSAttributedString.Key:Any] {
        return backingStore.attributes(at: location, effectiveRange: range)
    }

    override func replaceCharacters(in range: NSRange, with str: String) {
        beginEditing()
        backingStore.replaceCharacters(in: range, with:str)
        edited(.editedCharacters, range: range,
               changeInLength: (str as NSString).length - range.length)
        endEditing()
    }

    override func setAttributes(_ attrs: [NSAttributedString.Key: Any]?, range: NSRange) {
        beginEditing()
        backingStore.setAttributes(attrs, range: range)
        edited(.editedAttributes, range: range, changeInLength: 0)
        endEditing()
    }
}

1 个答案:

答案 0 :(得分:5)

您已经在Swift中找到了一个bug(也许不仅是Swift库中的bug,还可能是更基本的东西)。

那是怎么回事?

如果您创建编号列表而不是项目符号列表,则可以更好地看到这一点。您无需做任何复制和粘贴,只需:

  1. 输入“ aa”,按回车键,输入“ bb”
  2. 选择全部并格式化为编号列表
  3. 将光标置于“ aa”的末尾并按回车键...

您看到的是一个烂摊子,但是您可以看到两个原始数字仍然存在,并且您按回车键开始的新中间列表项就是所有烂摊子所在的地方。

点击回车键时,文本系统必须重新编号列表项,因为您刚刚插入了新项。首先,事实证明,即使它是一个项目符号列表,它也会执行此“重新编号”,这就是在示例中看到混乱的原因。其次,它通过从列表的开头开始并为每个列表项重新编号并为刚创建的项目插入新编号来进行重新编号。

Objective-C中的过程

如果将Swift代码转换为等效的Objective-C并进行跟踪,则可以观察该过程。开头:

1) aa
2) bb

内部缓冲区类似于:

\t1)\taa\n\t2)\tbb

首先插入返回值:

\t1)\taa\n\n\t2)\tbb

,然后调用内部例程_reformListAtIndex:,并开始“重新编号”。首先,它将\t1)\t替换为\t1)-数字未更改。然后它将\t2)\t插入两行之间,因为这时我们有了:

\t1)\taa\n\t2)\t\n\t2)\tbb

,然后将其替换为\t2)\t,将原来的\t3)\t替换为:

\t1)\taa\n\t2)\t\n\t3)\tbb

它的工作就完成了。所有这些替换都是基于指定要替换的字符范围,插入使用长度为0的范围,然后进行以下操作:

- (void)replaceCharactersInRange:(NSRange)range withString:(NSString * _Nonnull)str

在Swift中被替换为:

override func replaceCharacters(in range: NSRange, with str: String)

Swift进程

在Objective-C字符串中具有引用语义,请更改字符串,并在对该字符串进行引用的代码的所有部分中进行更改。在Swift字符串中,它们具有值语义,而字符串在传递给函数等时至少(至少在概念上)被复制。如果在调用的函数中更改了副本,则调用者将不会在副本中看到该更改。

文本系统是用(或为)Objective-C编写的,因此可以合理地假设它可以利用参考语义。当您用Swift替换其代码的一部分时,Swift代码必须做些跳舞,在replaceCharacters()被调用的列表重新编号阶段,堆栈看起来像:

#0  0x0000000100003470 in SwiftTextStorage.replaceCharacters(in:with:)
#1  0x0000000100003a00 in @objc SwiftTextStorage.replaceCharacters(in:with:) ()
#2  0x00007fff2cdc30c7 in -[NSMutableAttributedString replaceCharactersInRange:withAttributedString:] ()
#3  0x00007fff28998c41 in -[NSTextView(NSKeyBindingCommands) _reformListAtIndex:] ()
#4  0x00007fff284fd555 in -[NSTextView(NSKeyBindingCommands) insertNewline:] ()

第4帧是在按下返回键时调用的Objective-C代码,在插入换行符后,它调用内部例程_reformListAtIndex:,第3帧进行重新编号。这会在第2帧中调用另一个Objective-C例程,然后又调用第1帧,它认为是Objective-C方法replaceCharactersInRange:withString:,但实际上是Swift的替代方法。这次替换完成了一些工作,将Objective-C参考语义字符串转换为Swift值语义字符串,然后调用帧#0,Swift replaceCharacters()

跳舞很辛苦

如果在重新编号进入将原始\t2)\t更改为\t3)\t的阶段时,就像在Objective-C转换中一样浏览Swift代码,则会看到错误的步调,即给出的范围因为原来的\t2)\t之前的意思,所以新的\t2)\t插入了上一步(即,它偏离了4个位置)...最终一团糟,随后又跳了一些舞步,代码由于引用错误的字符串而崩溃,原因是索引全部错误。

这表明Objective-C代码依赖于引用语义,并且Swift舞蹈的编舞者将引用转换为值并转换回引用语义未能达到Objective-C代码的期望:因此,当Objective-C代码C代码或一些替换掉它的Swift代码将计算原来的\t2)\t的范围,该范围是由先前插入新\t2)\t并没有改变的字符串得出的。

困惑吗?好的舞蹈有时会让你头晕;-)

修正?

在Objective-C中为NSTextStorage的子类编码,然后转到bugreport.apple.com并报告错误。

HTH(不仅仅是让您晕眩)