Created
October 23, 2013 23:36
-
-
Save abhibeckert/7128740 to your computer and use it in GitHub Desktop.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// | |
// 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