NSTextView自定义双击选择



如果NSTextView包含以下内容:

SELECT someTable.someColumn FROM someTable

用户双击someTable.someColumn,整个内容就会被选中(周期的两侧)。在这种特定情况下(查询编辑器),选择someTablesomeColumn将更有意义。

我试着四处看看是否能找到一种定制选择的方法,但到目前为止我还没能。

目前,我想做的是将NSTextView子类化,并做一些事情,比如:

- (void)mouseDown:(NSEvent *)theEvent
{
if(theEvent.clickCount == 2)
{
// TODO: Handle double click selection.
}
else
{
[super mouseDown:theEvent];
}
}

有人对此有什么想法或替代方案吗?(我缺少的另一种方法可能更适合重写吗)?

首先,与前面的答案相反,NSTextViewselectionRangeForProposedRange:granularity:方法不是实现这一点的正确方法。在苹果的"Cocoa文本架构"文档中(https://developer.apple.com/library/prerelease/mac/documentation/TextFonts/Conceptual/CocoaTextArchitecture/TextEditing/TextEditing.html–参见"划分NSTextView子类"一节)苹果明确表示"这些机制不是为了改变语言单词的定义(比如双击选择的内容)。"我不确定苹果为什么会有这种感觉,但我怀疑这是因为selectionRangeForProposedRange:granularity:没有得到任何关于建议范围的哪一部分是初始点击点的信息,与用户拖动到的位置相对应;要使双击拖动行为正确,可能很难覆盖此方法。也许还有其他问题,我不知道;医生有点神秘。也许苹果计划稍后对选择机制进行修改,以打破这种覆盖。也许在定义什么是"词"的其他方面,这里的重写无法解决。谁知道呢;但当苹果公司做出这样的声明时,遵循他们的指示通常是个好主意。

奇怪的是,苹果的文档接着说:"选择的细节是在文本系统的较低级别(目前是私有的)处理的。"我认为这已经过时了,因为事实上确实存在所需的支持:NSAttributedString上的doubleClickAtIndex:方法(属于NSAttributedStringKitAdditions类别)。Cocoa文本系统使用此方法(在NSAttributedStringNSTextStorage子类中)来确定单词边界。子类NSTextStorage有点棘手,所以我将在这里提供一个名为MyTextStorage的子类的完整实现。这些用于NSTextStorage子类化的代码大部分来自苹果公司的Ali Ozer。

MyTextStorage .h:中

@interface MyTextStorage : NSTextStorage
- (id)init;
- (id)initWithAttributedString:(NSAttributedString *)attrStr;
@end

MyTextStorage.m:中

@interface MyTextStorage ()
{
NSMutableAttributedString *contents;
}
@end
@implementation MyTextStorage
- (id)initWithAttributedString:(NSAttributedString *)attrStr
{
if (self = [super init])
{
contents = attrStr ? [attrStr mutableCopy] : [[NSMutableAttributedString alloc] init];
}
return self;
}
- init
{
return [self initWithAttributedString:nil];
}
- (void)dealloc
{
[contents release];
[super dealloc];
}
// The next set of methods are the primitives for attributed and mutable attributed string...
- (NSString *)string
{
return [contents string];
}
- (NSDictionary *)attributesAtIndex:(NSUInteger)location effectiveRange:(NSRange *)range
{
return [contents attributesAtIndex:location effectiveRange:range];
}
- (void)replaceCharactersInRange:(NSRange)range withString:(NSString *)str
{
NSUInteger origLen = [self length];
[contents replaceCharactersInRange:range withString:str];
[self edited:NSTextStorageEditedCharacters range:range changeInLength:[self length] - origLen];
}
- (void)setAttributes:(NSDictionary *)attrs range:(NSRange)range
{
[contents setAttributes:attrs range:range];
[self edited:NSTextStorageEditedAttributes range:range changeInLength:0];
}
// And now the actual reason for this subclass: to provide code-aware word selection behavior
- (NSRange)doubleClickAtIndex:(NSUInteger)location
{
// Start by calling super to get a proposed range.  This is documented to raise if location >= [self length]
// or location < 0, so in the code below we can assume that location indicates a valid character position.
NSRange superRange = [super doubleClickAtIndex:location];
NSString *string = [self string];
// If the user has actually double-clicked a period, we want to just return the range of the period.
if ([string characterAtIndex:location] == '.')
return NSMakeRange(location, 1);
// The case where super's behavior is wrong involves the dot operator; x.y should not be considered a word.
// So we check for a period before or after the anchor position, and trim away the periods and everything
// past them on both sides.  This will correctly handle longer sequences like foo.bar.baz.is.a.test.
NSRange candidateRangeBeforeLocation = NSMakeRange(superRange.location, location - superRange.location);
NSRange candidateRangeAfterLocation = NSMakeRange(location + 1, NSMaxRange(superRange) - (location + 1));
NSRange periodBeforeRange = [string rangeOfString:@"." options:NSBackwardsSearch range:candidateRangeBeforeLocation];
NSRange periodAfterRange = [string rangeOfString:@"." options:(NSStringCompareOptions)0 range:candidateRangeAfterLocation];
if (periodBeforeRange.location != NSNotFound)
{
// Change superRange to start after the preceding period; fix its length so its end remains unchanged.
superRange.length -= (periodBeforeRange.location + 1 - superRange.location);
superRange.location = periodBeforeRange.location + 1;
}
if (periodAfterRange.location != NSNotFound)
{
// Change superRange to end before the following period
superRange.length -= (NSMaxRange(superRange) - periodAfterRange.location);
}
return superRange;
}
@end

最后一部分实际上是在您的文本视图中使用您的自定义子类。如果你也有一个NSTextView子类,你可以在它的wakeFromNib方法中做到这一点;否则,只要你有机会,就在你的笔尖加载后立即这样做;例如,在相关窗口或控制器的wakeFromNib调用中,或者只是在调用之后加载包含文本视图的nib。在任何情况下,您都希望这样做(其中textview是您的NSTextView对象):

[[textview layoutManager] replaceTextStorage:[[[MyTextStorage alloc] init] autorelease]];

有了这个,你应该很好地离开,除非我在翻译这个时犯了错误!

最后,请注意,NSAttributedString中还有另一种方法nextWordFromIndex:forward:,当用户将插入点移动到下一个/上一个单词时,Cocoa的文本系统会使用它。如果你想让这类东西遵循相同的单词定义,你也需要将其子类化。对于我的应用程序,我没有这样做——我希望下一个/上一个单词在整个a.b.c.d序列上移动(或者更准确地说,我只是不在乎)——所以我没有在这里分享它的实现。留给读者练习。

NSTextView的子类中,应该重写-selectionRangeForProposedRange:granularity:,类似于:

-(NSRange)selectionRangeForProposedRange:(NSRange)proposedSelRange granularity:(NSSelectionGranularity)granularity
{
if (granularity == NSSelectByWord)
{
NSRange doubleRange = [[self textStorage] doubleClickAtIndex:proposedSelRange.location];
if (doubleRange.location != NSNotFound)
{
NSRange dotRange = [[[self textStorage] string] rangeOfString:@"." options:0 range:doubleRange];
if (dotRange.location != NSNotFound)
{
// double click after '.' ?
if (dotRange.location < proposedSelRange.location)
return NSMakeRange(dotRange.location + 1, doubleRange.length - (dotRange.location-doubleRange.location) - 1);
else
return NSMakeRange(doubleRange.location, dotRange.location-doubleRange.location);
}
}
}
return [super selectionRangeForProposedRange:proposedSelRange granularity:granularity];
}

这里是@bhaller代码在Swift 5中的自定义实现,非常感谢!

请注意,由于内存效率的原因,它不使用stringNSMutableAttributedString,使用另一个NSTextStorage更好。更多信息点击这里

final class MyTextStorage: NSTextStorage {
private var storage = NSTextStorage()
// MARK: - Required overrides for NSTextStorage
override var string: String {
return storage.string
}
override func attributes(at location: Int, effectiveRange range: NSRangePointer?) -> [NSAttributedString.Key : Any] {
return storage.attributes(at: location, effectiveRange: range)
}
override func replaceCharacters(in range: NSRange, with str: String) {
beginEditing()
storage.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()
storage.setAttributes(attrs, range: range)
edited(.editedAttributes, range: range, changeInLength: 0)
endEditing()
}
// MARK: - DOuble click functionality
override func doubleClick(at location: Int) -> NSRange {
// Call super to get location of the double click
var range = super.doubleClick(at: location)
let stringCopy = self.string
// If the user double-clicked a period, just return the range of the period
let locationIndex = stringCopy.index(stringCopy.startIndex, offsetBy: location)
guard stringCopy[locationIndex] != "." else {
return NSMakeRange(location, 1)
}
// The case where super's behavior is wrong involves the dot operator; x.y should not be considered a word.
// So we check for a period before or after the anchor position, and trim away the periods and everything
// past them on both sides. This will correctly handle longer sequences like foo.bar.baz.is.a.test.
let candidateRangeBeforeLocation = NSMakeRange(range.location, location - range.location)
let candidateRangeAfterLocation = NSMakeRange(location + 1, NSMaxRange(range) - (location + 1))
let periodBeforeRange = (stringCopy as NSString).range(of: ".", options: .backwards, range: candidateRangeBeforeLocation)
let periodAfterRange = (stringCopy as NSString).range(of: ".", options: [], range: candidateRangeAfterLocation)
if periodBeforeRange.location != NSNotFound {
// Change range to start after the preceding period; fix its length so its end remains unchanged
range.length -= (periodBeforeRange.location + 1 - range.location)
range.location = periodBeforeRange.location + 1
}
if periodAfterRange.location != NSNotFound {
// Change range to end before the following period
range.length -= (NSMaxRange(range) - periodAfterRange.location);
}
return range
}
}

编辑:谷歌帮我找到了这篇文章——由于性能问题,不建议使用Swift版本。

最新更新