Mac 实现指标语法高亮文本编辑器

panger0条评论 3,698 次浏览

Mac 实现指标语法高亮文本编辑器


语法高亮简介

Cocoa AppKit 提供了一个叫做NSTextStorage的类,可以实现高亮功能,这是它的结构

先暂且不谈这个结构,我们看一下NSTextView提供的一个构造接口:

- (instancetype)initWithFrame:(NSRect)frameRect textContainer:(nullable NSTextContainer *)container;

NSTextView 除了我们常见的构造方法外,还提供了一个这样的构造方法,它接受一个 NSTextContainer 类型的参数。 再看看我们上面那张图中的 NSTextContainer, 稍微建立起一点关系了。

如果以 MVC 的思路来思考的话:

  • NSTextStorage 相当于模型层,用于管理文本的底层存储,以及如何定义文本显示的样式。 高亮显示功能的主要代码都是在这个类中完成的。

  • NSLayoutManager 相当于控制器层,它负责把 NSTextStorage 的文本内容绘制到相应的视图上,并且它还负责文字的排版处理等。

  • NSTextContainer 定义了文本在 NSTextView 上面的显示区域。

基于这些分层的概念,我们可以把同一个 NSTextStorage 同时显示到两个不同的视图上面。

重写 NSTextStorage

首先实现一个继承自 NSTextStorage 的类:

@interface FTCIndicatorHighlightingTextStorage : NSTextStorage

@end

定义一个 NSMutableAttributedString 类型的属性,它用于我们的底层存储,然后实现几个约定的 getter 和 setter 方法,对_imp对象相应方法的一个包装:

@implementation FTCIndicatorHighlightingTextStorage  {
    NSMutableAttributedString *_imp;
}

- (instancetype)init {
    if (self = [super init]) {
        _imp = [NSMutableAttributedString new];
    }
    return self;
}

#pragma mark - Reading Text

- (NSString *)string {
    return _imp.string;
}

- (NSDictionary<NSString *,id> *)attributesAtIndex:(NSUInteger)location effectiveRange:(NSRangePointer)range {
    return [_imp attributesAtIndex:location effectiveRange:range];
}

#pragma mark - Text Editing

- (void)replaceCharactersInRange:(NSRange)range withString:(NSString *)str {
    [_imp replaceCharactersInRange:range withString:[str uppercaseString]];
    [self edited:NSTextStorageEditedCharacters range:range changeInLength:(NSInteger)str.length - (NSInteger)range.length];
}

- (void)setAttributes:(NSDictionary<NSString *,id> *)attrs range:(NSRange)range {
    [_imp setAttributes:attrs range:range];
    [self edited:NSTextStorageEditedAttributes range:range changeInLength:0];
}

@end

接下来,我们实现最关键的方法 processEditing, 这个方法会在 NSTextView 的文本被更改的时候被调用,我们可以在这里匹配出要进行高亮的关键字:

- (void)processEditing {
    [super processEditing];
    // 这里根据需要使用正则匹配对需要被高亮的文本做处理,具体可参考下图指标高亮处理逻辑的代码示例
    // 对需要被高亮的文本设置相应的属性
    // [self addAttribute:NSForegroundColorAttributeName value:color range:wordRange];
}

指标编辑支持代码注释,scriptRanges为第一次正则匹配后去掉所有注释代码后,有效代码的Range集合

重写 NSLayoutManager

为了尽量封装,对外使用更加便捷,我们这里重写NSLayoutManager,将NSTextStorage直接与NSLayoutManager关联起来。当然也可以在外部手动关联。废话少说,直接代码:

@interface FTCIndicatorHighlightingTSLayoutManager : NSLayoutManager

@property (nonatomic, strong) FTCIndicatorHighlightingTextStorage *scriptTextStorage;

@end

@implementation FTCIndicatorHighlightingTSLayoutManager

- (instancetype)init {
    if (self = [super init]) {
        _scriptTextStorage = [FTCIndicatorHighlightingTextStorage new];
        [_scriptTextStorage addLayoutManager:self];
        self.delegate = self;
    }
    return self;
}

@end

重写 NSTextView

实际需求中,代码编辑器需要在左侧支持行号显示,为了代码结构上更加紧凑,我们重写了NSTextView,使用运行时的方式关联了一个NSRulerView,以下为重写的NSTextView:

// --- FTCIndicatorTextView.h

@interface FTCIndicatorTextView : NSTextView

@end


@interface NSTextView (FTCLineNumTextView)

- (void)lnv_setUpLineNumberView;

@end

// --- FTCIndicatorTextView.m

@implementation FTCIndicatorTextView

@end

@interface NSTextView()

@property (nonatomic, strong) FTCIndicatorLineNumberRulerView *lineNumberView;

@end

@implementation NSTextView (FTCLineNumTextView)
- (void)dealloc {
    [[NSNotificationCenter defaultCenter] removeObserver:self];
}

- (FTCIndicatorLineNumberRulerView *)lineNumberView {
    return objc_getAssociatedObject(self, _cmd);
}

- (void)setLineNumberView:(FTCIndicatorLineNumberRulerView *)lineNumberView {
    objc_setAssociatedObject(self, @selector(lineNumberView), lineNumberView, OBJC_ASSOCIATION_RETAIN_NONATOMIC);
}

- (void)lnv_setUpLineNumberView {
    if (!self.font) {
        self.font = [NSFont systemFontOfSize:[NSFont smallSystemFontSize]];
    }
    self.lineNumberView = [[FTCIndicatorLineNumberRulerView alloc] initWithTextView:self];

    NSScrollView *scrollView = self.enclosingScrollView;
    scrollView.verticalRulerView = self.lineNumberView;
    scrollView.hasVerticalRuler = YES;
    scrollView.rulersVisible = YES;

    self.postsFrameChangedNotifications = YES;
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(lnv_frameDidChange:) name:NSViewFrameDidChangeNotification object:self];
    [[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(lnv_textDidChange:) name:NSTextDidChangeNotification object:self];
}

- (void)lnv_frameDidChange:(NSNotification *)notify {
    [self.lineNumberView setNeedsDisplay:YES];
}

- (void)lnv_textDidChange:(NSNotification *)notify {
    [self.lineNumberView setNeedsDisplay:YES];
}

@end

此处再贴一下FTCIndicatorLineNumberRulerView的核心实现,这个负责行号如何绘制:

// FTCIndicatorLineNumberRulerView.h

@interface FTCIndicatorLineNumberRulerView : NSRulerView

- (instancetype)initWithTextView:(NSTextView *)textView;

@end

// FTCIndicatorLineNumberRulerView.m

@implementation FTCIndicatorLineNumberRulerView

- (instancetype)initWithTextView:(NSTextView *)textView {
    self = [super initWithScrollView:textView.enclosingScrollView orientation:NSVerticalRuler];
    if (self) {
        self.font = textView.font ? textView.font : [NSFont systemFontOfSize:[NSFont smallSystemFontSize]];
        self.clientView = textView;
        self.ruleThickness = 27;
    }
    return self;
}

- (void)drawLineNumber:(NSString *)numStr y:(CGFloat)y relativePoint:(NSPoint)relaPoint{
    NSAttributedString *aString = [[NSAttributedString alloc] initWithString:numStr attributes:self.attribute];
    CGFloat x = 24 - aString.size.width;
    [aString drawAtPoint:NSMakePoint(x, y + relaPoint.y - 2)];
}

- (void)drawHashMarksAndLabelsInRect:(NSRect)rect {
    NSRect divideLineRect = rect;
    divideLineRect.origin.x = rect.size.width - 1;
    divideLineRect.size.width = 1;
    [[FTCTheme defaultTheme].borderLineColor set];
    NSRectFill(divideLineRect);

    NSTextView *textView = (NSTextView *)self.clientView;
    if ([textView isKindOfClass:[NSTextView class]]) {
        NSLayoutManager *layoutManager = textView.layoutManager;
        NSPoint relativePoint = [self convertPoint:NSZeroPoint fromView:textView];

        NSRange visibleGlyphRange = [layoutManager glyphRangeForBoundingRect:textView.visibleRect inTextContainer:textView.textContainer];
        NSInteger firstVisibleGlyphCharacterIndex = [layoutManager characterIndexForGlyphAtIndex:visibleGlyphRange.location];
        NSRegularExpression *newLineRegex = [NSRegularExpression regularExpressionWithPattern:@"\n" options:NSRegularExpressionCaseInsensitive error:nil];
        NSInteger lineNumber = [newLineRegex numberOfMatchesInString:textView.string options:NSMatchingReportProgress range:NSMakeRange(0, firstVisibleGlyphCharacterIndex)] + 1;
        NSInteger glyphIndexForStringLine = visibleGlyphRange.location;
        while (glyphIndexForStringLine < NSMaxRange(visibleGlyphRange)) {
            NSRange charactorRangeForStringLine = [textView.string lineRangeForRange:NSMakeRange([layoutManager characterIndexForGlyphAtIndex:glyphIndexForStringLine], 0)];
            NSRange glyphRangeForStringLine = [layoutManager glyphRangeForCharacterRange:charactorRangeForStringLine actualCharacterRange:nil];
            NSInteger glyphIndexForGlyphLine = glyphIndexForStringLine;
            NSInteger glyphLineCount = 0;

            while (glyphIndexForGlyphLine < NSMaxRange(glyphRangeForStringLine)) {
                NSRange effectiveRange = NSMakeRange(0, 0);
                NSRect lineRect = [layoutManager lineFragmentRectForGlyphAtIndex:glyphIndexForGlyphLine effectiveRange:&effectiveRange];
                if (glyphLineCount > 0) {
                    [self drawLineNumber:@"" y:NSMinY(lineRect) relativePoint:relativePoint];
                } else {
                    [self drawLineNumber:[NSString stringWithFormat:@"%ld", lineNumber] y:NSMinY(lineRect) relativePoint:relativePoint];
                }
                glyphLineCount++;
                glyphIndexForGlyphLine = NSMaxRange(effectiveRange);
            }
            glyphIndexForStringLine = NSMaxRange(glyphRangeForStringLine);
            lineNumber++;
        }
        if ([layoutManager extraLineFragmentTextContainer]) {
            [self drawLineNumber:[NSString stringWithFormat:@"%ld", lineNumber] y:NSMinY(layoutManager.extraLineFragmentRect) relativePoint:relativePoint];
        }
    }
}

设置 NSTextView

以上我们完成了 NSTextStorage/NSTextView/NSLayoutManager 子类的实现,现在我们就可以用它来构建语法高亮编辑器了:

@interface FTCIndicatorScriptEditViewController : NSViewController

@end

@implementation FTCIndicatorScriptEditViewController

/*... 此处省略若干代码 ...*/

- (void)setupContentTextView {    
    NSRect frame = self.rightEdgeContainerView.frame;

    NSScrollView *scrollview = [[NSScrollView alloc] initWithFrame:NSMakeRect(0, 0, frame.size.width, frame.size.height)];
    [scrollview setBorderType:NSNoBorder];
    [scrollview setHasVerticalScroller:YES];
    [scrollview setHasHorizontalScroller:NO];
    [self.rightEdgeContainerView addSubview:scrollview];
    [scrollview setAutoresizingMask:NSViewWidthSizable | NSViewHeightSizable];

    NSSize contentSize = [scrollview contentSize];
    NSTextContainer *textContainer = [[NSTextContainer alloc] initWithContainerSize:contentSize];
    FTCIndicatorTextView *contentText = [[FTCIndicatorTextView alloc] initWithFrame:NSMakeRect(0, 0, contentSize.width, contentSize.height) textContainer:textContainer];
    [contentText setMinSize:NSMakeSize(0.0, contentSize.height)];
    [contentText setMaxSize:NSMakeSize(FLT_MAX, FLT_MAX)];
    [contentText setVerticallyResizable:YES];
    [contentText setHorizontallyResizable:NO];
    [contentText setAutoresizingMask:NSViewWidthSizable];
    [[contentText textContainer] setContainerSize:NSMakeSize(contentSize.width, FLT_MAX)];
    [[contentText textContainer] setWidthTracksTextView:YES];
    [scrollview setDocumentView:contentText];

    [self.scriptTSLayoutManager addTextContainer:textContainer];
    contentText.font = [FTCTheme defaultTheme].textDefaultFont;
    [contentText lnv_setUpLineNumberView];
    contentText.allowsUndo = YES;
    _contentText = contentText;
}
/*... 此处省略若干代码 ...*/

@end

至此对此文开头的第一张贴图,会有一个更加清晰的认识。

再贴一张最终实现的效果图:


发表评论

? razz sad evil ! smile oops grin eek shock ??? cool lol mad twisted roll wink idea arrow neutral cry mrgreen