Skip to content

Instantly share code, notes, and snippets.

@abhibeckert
Created October 23, 2013 23:36
Show Gist options
  • Save abhibeckert/7128740 to your computer and use it in GitHub Desktop.
Save abhibeckert/7128740 to your computer and use it in GitHub Desktop.
//
// DuxTextView.m
// DuxTextView
//
// Created by Abhi Beckert on 19/10/2013.
// Copyright (c) 2013 Abhi Beckert. All rights reserved.
//
#import "DuxTextView.h"
@interface DuxTextView ()
@property (strong) NSOperationQueue *layoutQueue;
@property (strong) NSMutableIndexSet *lineBeginnings; // must only be read or written to on the main thread!
@property (strong) NSCache *lineLayerCache;
@end
@implementation DuxTextView
- (id)initWithFrame:(NSRect)frame
{
if (!(self = [super initWithFrame:frame]))
return nil;
self.wantsLayer = YES;
self.textAttributes = @{NSFontAttributeName: [NSFont fontWithName:@"Menlo" size:13], NSForegroundColorAttributeName: (id)[[NSColor textColor] CGColor]};
self.layoutQueue = [[NSOperationQueue alloc] init];
self.layoutQueue.maxConcurrentOperationCount = 1;
self.lineLayerCache = [[NSCache alloc] init];
self.lineLayerCache.countLimit = 200;
self.lineBeginnings = [NSMutableIndexSet indexSet];
return self;
}
- (void)awakeFromNib
{
[super awakeFromNib];
[self.superview setPostsBoundsChangedNotifications:YES];
[[NSNotificationCenter defaultCenter] addObserver:self selector:@selector(boundsDidChange:) name:NSViewBoundsDidChangeNotification object:self.superview];
}
- (void)boundsDidChange:(NSNotification *)notif
{
[self updateVisibleLines];
}
- (BOOL)isFlipped
{
return YES;
}
- (void)setContents:(NSData *)contents
{
// update contents
_contents = contents;
// layout lines
[self layoutLines];
}
- (void)setFrame:(NSRect)frameRect
{
// lock horizontal space to the full width of the enclosing scroll view
frameRect.origin.x = 0;
frameRect.size.width = self.enclosingScrollView.frame.size.width;
// do not allow the y coordinate to change. we will call [super setFrame:] if we want that to happen (this prevents NSScrollView from pinning us to the bottom edge instead of the top edge)
frameRect.origin.y = self.frame.origin.y;
// did the width change? we need to re-layout all the lines
BOOL widthChange = fabs(self.frame.size.width - frameRect.size.width) > 0.1;
[super setFrame:frameRect];
// either relayout lines, or just update visible ones
if (widthChange) {
[self layoutLines];
} else {
[self updateVisibleLines];
}
}
-(void)layoutLines
{
[self.layoutQueue cancelAllOperations];
NSData *contents = self.contents.copy;
CGFloat width = self.enclosingScrollView.frame.size.width;
__block CGFloat yOffset = 0;
__block NSBlockOperation *layoutBlock = [NSBlockOperation blockOperationWithBlock:^{
NSMutableIndexSet *scratchLineBeginnings = [NSMutableIndexSet indexSet];
[scratchLineBeginnings addIndex:0];
__block BOOL clearLineBeginnings = YES;
NSUInteger byteLocation = 0;
NSUInteger bytesLength = 0;
NSUInteger bytesBufferSize = ceil(width / [@"." sizeWithAttributes:self.textAttributes].width) * 3; // max number of characters that can fit one one line, multiplied by 3 since they might all be unicode characters
UInt8 bytes[bytesBufferSize];
NSRect visibleRect = self.enclosingScrollView.documentVisibleRect;
NSUInteger minLineIndex = MAX(0, (visibleRect.origin.y - DUX_LINE_HEIGHT) / DUX_LINE_HEIGHT);
NSUInteger maxLineIndex = (visibleRect.origin.y + visibleRect.size.height + DUX_LINE_HEIGHT) / DUX_LINE_HEIGHT;
BOOL lastLineDidNeedRender = NO;
NSUInteger lineIndex = 0;
while (byteLocation < contents.length) {
if (layoutBlock.isCancelled)
break;
@autoreleasepool {
// fetch 1,000 bytes
bytesLength = MIN(contents.length - byteLocation, bytesBufferSize);
[contents getBytes:&bytes range:NSMakeRange(byteLocation, bytesLength)];
// check if the end of the bytes is inside a utf-8 sequence
if (bytesLength > 0) {
UInt8 lastByte = bytes[bytesLength - 1];
if ((lastByte & 0x80) != 0) { // is last byte a non-ASCII character?
while (((lastByte & 0xc0) == 0x80)) { // walk backwards until we have reached the first char in the utf-8 sequence
bytesLength--;
lastByte = bytes[bytesLength-1];
}
bytesLength--; // now go back 1 more byte (to skip the first char in utf-8 sequence)
}
}
// create string from data
CFStringRef stringContents = CFStringCreateWithBytes(NULL, bytes, bytesLength, kCFStringEncodingUTF8, 0); // TODO: support other encodings
// search for a newline or wrapped line
CFAttributedStringRef attributedString = CFAttributedStringCreate(NULL, stringContents, (__bridge CFDictionaryRef)(self.textAttributes));
CTTypesetterRef typesetter = CTTypesetterCreateWithAttributedString(attributedString);
CFIndex lineLength = CTTypesetterSuggestLineBreak(typesetter, 0, width - (DUX_LINE_LEFT_MARGIN + DUX_LINE_RIGHT_MARGIN));
// create an attributed string for the line
CFAttributedStringRef lineString = CFAttributedStringCreateWithSubstring(NULL, attributedString, CFRangeMake(0, lineLength));
// TODO: syntax highlighting might go here
// count how many bytes the attributed string is
CFIndex lineBytesLength;
CFStringGetBytes(CFAttributedStringGetString(lineString), CFRangeMake(0, lineLength), kCFStringEncodingUTF8, 0, YES, NULL, ULONG_MAX, &lineBytesLength); //TODO: support other encodings
// save the line
[scratchLineBeginnings addIndex:byteLocation];
// clean up
CFRelease(typesetter);
CFRelease(attributedString);
CFRelease(lineString);
CFRelease(stringContents);
// is the line we just created visible?
BOOL needsRender = (lineIndex >= minLineIndex && lineIndex <= maxLineIndex);
// we render if this line doesn't need to be rendered but the last one did (i.e. it was the last visible line)
if (lastLineDidNeedRender && !needsRender) {
dispatch_sync(dispatch_get_main_queue(), ^{
if (layoutBlock.isCancelled)
return;
if (clearLineBeginnings) {
clearLineBeginnings = NO;
[self.lineBeginnings removeAllIndexes];
}
[self.lineBeginnings addIndexes:scratchLineBeginnings];
[scratchLineBeginnings removeAllIndexes];
[self.lineLayerCache removeAllObjects];
if (self.frame.size.height < yOffset) {
[super setFrame:NSMakeRect(self.frame.origin.x, self.frame.origin.y, self.frame.size.width, yOffset)]; // note we call *super*
[self.enclosingScrollView flashScrollers];
}
[self updateVisibleLines];
});
}
// every 10,000 lines send our progress to the main thread
if ((lineIndex % 10000) == 0) {
dispatch_sync(dispatch_get_main_queue(), ^{
if (layoutBlock.isCancelled)
return;
if (clearLineBeginnings) {
clearLineBeginnings = NO;
[self.lineBeginnings removeAllIndexes];
}
[self.lineBeginnings addIndexes:scratchLineBeginnings];
[scratchLineBeginnings removeAllIndexes];
[self.lineLayerCache removeAllObjects];
// has our height increased?
if (yOffset > self.frame.size.height) {
[super setFrame:NSMakeRect(self.frame.origin.x, self.frame.origin.y, self.frame.size.width, yOffset)]; // note we call *super*
[self.enclosingScrollView flashScrollers];
}
});
}
// move to the next line
byteLocation += lineBytesLength;
yOffset += DUX_LINE_HEIGHT;
lineIndex++;
lastLineDidNeedRender = needsRender;
}
}
if (layoutBlock.isCancelled)
return;
// render one last time
dispatch_sync(dispatch_get_main_queue(), ^{
if (layoutBlock.isCancelled)
return;
if (clearLineBeginnings) {
clearLineBeginnings = NO;
[self.lineBeginnings removeAllIndexes];
}
[self.lineBeginnings addIndexes:scratchLineBeginnings];
[scratchLineBeginnings removeAllIndexes];
[self.lineLayerCache removeAllObjects];
if (fabsf(self.frame.size.height - yOffset) > 0.1) {
[super setFrame:NSMakeRect(self.frame.origin.x, self.frame.origin.y, self.frame.size.width, yOffset)]; // note we call *super*
}
[self updateVisibleLines];
});
}];
[self.layoutQueue addOperation:layoutBlock];
}
- (void)updateVisibleLines
{
if (![[NSThread currentThread] isEqual:[NSThread mainThread]])
[NSException raise:@"oops!" format:@"need to be on the main thread"];
NSUInteger lineCount = self.lineBeginnings.count;
NSUInteger lineBeginnings[lineCount];
[self.lineBeginnings getIndexes:lineBeginnings maxCount:lineCount inIndexRange:nil];
NSRect visibleRect = self.enclosingScrollView.documentVisibleRect;
NSUInteger minLineIndex = MAX(0, (visibleRect.origin.y - DUX_LINE_HEIGHT) / DUX_LINE_HEIGHT);
NSUInteger maxLineIndex = MIN(self.lineBeginnings.count, (visibleRect.origin.y + visibleRect.size.height + DUX_LINE_HEIGHT) / DUX_LINE_HEIGHT);
NSUInteger lastLineBeginning = lineBeginnings[0];
NSMutableSet *visibleLayers = [NSMutableSet setWithCapacity:self.layer.sublayers.count];
for (NSUInteger lineIndex = 1; lineIndex < lineCount; lineIndex++) {
NSUInteger lineBeginning = lineBeginnings[lineIndex];
if (lineIndex < minLineIndex || lineIndex > maxLineIndex) {
lastLineBeginning = lineBeginning;
continue;
}
DuxLine *line = [self lineWithByteRange:NSMakeRange(lastLineBeginning, lineBeginning - lastLineBeginning) lineNumber:lineIndex];
[line setFrameWithTopLeftOrigin:NSMakePoint(0, (lineIndex - 1) * DUX_LINE_HEIGHT) width:self.frame.size.width];
[visibleLayers addObject:line];
lastLineBeginning = lineBeginning;
}
[CATransaction begin];
for (CALayer *layer in self.layer.sublayers.copy) {
if ([visibleLayers containsObject:layer])
continue;
[layer removeFromSuperlayer];
}
for (CALayer *layer in visibleLayers) {
[self.layer addSublayer:layer];
}
[CATransaction commit];
}
- (DuxLine *)lineWithByteRange:(NSRange)range lineNumber:(NSUInteger)lineNumber
{
NSValue *lineCacheKey = [NSValue valueWithRange:range];
DuxLine *cachedLine = [self.lineLayerCache objectForKey:lineCacheKey];
if (cachedLine)
return cachedLine;
UInt8 bytes[range.length];
[self.contents getBytes:&bytes range:range];
CFStringRef stringContents = CFStringCreateWithBytes(NULL, bytes, range.length, kCFStringEncodingUTF8, 0); // TODO: support other encodings
CFAttributedStringRef lineString = CFAttributedStringCreate(NULL, stringContents, (__bridge CFDictionaryRef)(self.textAttributes));
DuxLine *line = [[DuxLine alloc] initWithString:lineString byteRange:range lineNumber:lineNumber];
line.autoresizingMask = NSViewMaxYMargin;
CFRelease(lineString);
CFRelease(stringContents);
[self.lineLayerCache setObject:line forKey:lineCacheKey];
return line;
}
@end
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment