Velvet/VELLabel.m
//
//  VELLabel.m
//  Velvet
//
//  Created by Josh Vera on 11/21/11.
//  Copyright (c) 2011 Bitswift. All rights reserved.
//

#import "VELLabel.h"
#import "EXTScope.h"

static NSString * const VELLabelEmptyAttributedString = @"\0";

static NSRange NSRangeFromCFRange(CFRange range) {
    if (range.location == kCFNotFound) {
        return NSMakeRange(NSNotFound, (NSUInteger)range.length);
    }
    else {
        return NSMakeRange((NSUInteger)range.location, (NSUInteger)range.length);
    }
}

@interface VELLabel ()
/*
 * Sets an attribute over the full length of the <formattedText>.
 *
 * This will replace any existing attributes with the given name.
 *
 * @param attributeName The name of the attribute to set.
 * @param value The value of the attribute. If this argument is `nil`, any
 * existing attributes are removed instead of being replaced.
 */
- (void)setAttribute:(NSString *)attributeName value:(id)value;

/*
 * Replaces the paragraph style of the <formattedText> with one created from the
 * receiver's style properties.
 */
- (void)setParagraphStyle;

/*
 * Returns a new array containing all `CTLineRef` objects necessary to draw the
 * given string within the given width.
 *
 * These lines may flow over the label's height.
 *
 * @param string The string from which to create lines.
 * @param maximumWidth A width to which to constrain the lines. Use
 * `CGFLOAT_MAX` to indicate that there is no constraint upon the width.
 */
- (NSMutableArray *)linesForAttributedString:(NSAttributedString *)string constrainedToWidth:(CGFloat)maximumWidth;

@end

@implementation VELLabel

#pragma mark Properties

@synthesize formattedText = m_formattedText;
@synthesize numberOfLines = m_numberOfLines;
@synthesize lineBreakMode = m_lineBreakMode;
@synthesize textAlignment = m_textAlignment;

- (void)setFormattedText:(NSAttributedString *)str {
    m_formattedText = [str copy];
    [self.layer setNeedsDisplay];
}

- (NSString *)text {
    NSString *str = self.formattedText.string;

    if ([str isEqualToString:VELLabelEmptyAttributedString])
        return nil;
    else
        return str;
}

- (void)setText:(NSString *)text {
    NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:VELLabelEmptyAttributedString];

    if (self.formattedText)
        [attributedString setAttributedString:self.formattedText];

    if (![text length])
        text = VELLabelEmptyAttributedString;

    [attributedString replaceCharactersInRange:NSMakeRange(0, attributedString.length) withString:text];

    self.formattedText = attributedString;
}

- (NSFont *)font {
    return [self.formattedText attribute:NSFontAttributeName atIndex:0 effectiveRange:NULL];
}

- (void)setFont:(NSFont *)font {
    [self setAttribute:NSFontAttributeName value:font];
}

- (NSColor *)textColor {
    return [self.formattedText attribute:NSForegroundColorAttributeName atIndex:0 effectiveRange:NULL];
}

- (void)setTextColor:(NSColor *)color {
    [self setAttribute:NSForegroundColorAttributeName value:color];
}

- (void)setNumberOfLines:(NSUInteger)numberOfLines {
    m_numberOfLines = numberOfLines;
    [self setNeedsDisplay];
}

- (VELTextAlignment)textAlignment {
    CTParagraphStyleRef paragraphStyle = (__bridge CTParagraphStyleRef)[self.formattedText attribute:NSParagraphStyleAttributeName atIndex:0 effectiveRange:NULL];

    CTTextAlignment textAlignment = 0;

    if (paragraphStyle) {
        CTParagraphStyleGetValueForSpecifier(paragraphStyle, kCTParagraphStyleSpecifierAlignment, sizeof(textAlignment), &textAlignment);
    }

    return textAlignment;
}

- (void)setTextAlignment:(VELTextAlignment)alignment {
    m_textAlignment = alignment;
    [self setParagraphStyle];
}

#pragma mark Lifecycle

- (id)init {
    self = [super init];
    if (!self)
        return nil;

    // don't automatically clear the context -- we'll fill it with our
    // background color in drawRect: so that we get SPAA on top
    self.clearsContextBeforeDrawing = NO;

    self.backgroundColor = [NSColor clearColor];
    self.numberOfLines = 1;
    self.userInteractionEnabled = NO;

    return self;
}


#pragma mark Line Calculations

- (NSMutableArray *)linesForAttributedString:(NSAttributedString *)string constrainedToWidth:(CGFloat)maximumWidth; {
    CTTypesetterRef typesetter = CTTypesetterCreateWithAttributedString((__bridge CFAttributedStringRef)string);
    @onExit {
        CFRelease(typesetter);
    };

    CFIndex strLength = (CFIndex)string.length;
    CFIndex characterIndex = 0;
    NSMutableArray *lines = [NSMutableArray array];

    while (characterIndex < strLength) {
        CFIndex characterCount = 0;

        switch (self.lineBreakMode) {
            case VELLineBreakModeCharacterWrap:
                characterCount = CTTypesetterSuggestClusterBreak(typesetter, characterIndex, maximumWidth);
                break;

            case VELLineBreakModeClip:
                characterCount = strLength;
                break;

            case VELLineBreakModeWordWrap:
            // Truncation is treated similar to word wrap before we condense it down and add an elipsis
            case VELLineBreakModeHeadTruncation:
            case VELLineBreakModeLastLineMiddleTruncation:
            case VELLineBreakModeTailTruncation:
            default:
                characterCount = CTTypesetterSuggestLineBreak(typesetter, characterIndex, maximumWidth);
                break;
        }

        CTLineRef line = CTTypesetterCreateLine(typesetter, CFRangeMake(characterIndex, characterCount));
        [lines addObject:(__bridge_transfer id)line];
        characterIndex += characterCount;
    }

    return lines;
}

#pragma mark Drawing

static NSAttributedString *attributedEllipsisForString(NSAttributedString *attributedString) {
    NSMutableAttributedString *mutableEllipsisString = [[NSMutableAttributedString alloc] initWithString:@"…"];
    NSDictionary *attributes = [attributedString attributesAtIndex:[attributedString length] - 1 effectiveRange:NULL];

    [mutableEllipsisString setAttributes:attributes range:NSMakeRange(0, mutableEllipsisString.length)];
    return mutableEllipsisString;
}

- (void)drawRect:(CGRect)rect {
    CGContextRef context = [NSGraphicsContext currentContext].graphicsPort;

    // clear our context to our background color
    if (self.layer.backgroundColor) {
        CGContextSetFillColorWithColor(context, self.layer.backgroundColor);
        CGContextFillRect(context, rect);
    } else {
        CGContextClearRect(context, rect);
    }

    NSAttributedString *attributedString = self.formattedText;
    if ([attributedString.string isEqualToString:VELLabelEmptyAttributedString])
        return;

    CGContextSetTextMatrix(context, CGAffineTransformIdentity);

    CGFloat drawableWidth = self.bounds.size.width;
    CGFloat drawableHeight = self.bounds.size.height;

    NSMutableArray *lines = [self linesForAttributedString:attributedString constrainedToWidth:drawableWidth];

    NSUInteger numberOfLinesToDraw = lines.count;
    BOOL shouldTruncate = (self.lineBreakMode == VELLineBreakModeHeadTruncation || self.lineBreakMode == VELLineBreakModeLastLineMiddleTruncation || self.lineBreakMode == VELLineBreakModeTailTruncation);

    // If we are truncating then we may need to adjust numberOfLinesToDraw
    if (shouldTruncate) {
        numberOfLinesToDraw = 0;

        // Determine if we have more lines than will fit vertically in our bounds
        CGFloat height = 0.0f;
        for (NSUInteger i = 0; i < lines.count; i++) {
            CTLineRef aLine = (__bridge CTLineRef)[lines objectAtIndex:i];

            CGFloat ascent;
            CGFloat descent;
            CGFloat leading;
            CTLineGetTypographicBounds(aLine, &ascent, &descent, &leading);

            height += ceil(ascent + descent + leading);

            // Only draw the line if it will fully fit in the bounds
            if (height > drawableHeight) {
                break;
            }
            numberOfLinesToDraw++;
        }
    }

    // If we have more lines than will fit and the label uses truncation, we'll need to cull the lines
    if (numberOfLinesToDraw == 0) {
        [lines removeAllObjects];
    } else if (numberOfLinesToDraw < lines.count) {
        NSAssert(shouldTruncate, @"Should only have a reduced number of lines if we're truncating");

        CTLineRef (^createEllipsisLineRefWithAttributesFromString) (NSAttributedString *) = ^(NSAttributedString *attributedString) {
            NSAttributedString *ellipsisString = attributedEllipsisForString(attributedString);
            return CTLineCreateWithAttributedString((__bridge CFAttributedStringRef)ellipsisString);
        };

        if (self.lineBreakMode == VELLineBreakModeHeadTruncation) {
            // Calculate the truncated first line if we have more lines than will be drawn
            //   and the label uses VELLineBreakModeHeadTruncation
            CTLineRef firstVisibleLine = (__bridge CTLineRef)[lines objectAtIndex:lines.count - numberOfLinesToDraw];
            NSRange firstLineRange = NSRangeFromCFRange(CTLineGetStringRange(firstVisibleLine));
            // what we really want is a range from the beginning to the end of `firstVisibleLine`
            firstLineRange.length = firstLineRange.location + firstLineRange.length;
            firstLineRange.location = 0;

            NSAttributedString *firstLineAttrStr = [attributedString attributedSubstringFromRange:firstLineRange];

            CTLineRef firstLine = CTLineCreateWithAttributedString((__bridge CFAttributedStringRef)firstLineAttrStr);
            CTLineRef ellipsisLine = createEllipsisLineRefWithAttributesFromString(firstLineAttrStr);
            @onExit {
                CFRelease(ellipsisLine);   
            };

            CTLineRef lineToDraw = CTLineCreateTruncatedLine(firstLine, drawableWidth, kCTLineTruncationStart, ellipsisLine);

            if (!lineToDraw)
                lineToDraw = firstLine;
            else
                CFRelease(firstLine);

            // Remove extra lines that we won't be drawing
            [lines removeObjectsInRange:NSMakeRange(0, lines.count - numberOfLinesToDraw)];

            // Replace the first line with our truncated version (transferring
            // its ownership to ARC in the same step)
            [lines replaceObjectAtIndex:0 withObject:(__bridge_transfer id)lineToDraw];
        } else {
            // Calculate the truncated last line if we have more lines than will
            // be drawn and the label has truncation affecting the last line
            CTLineRef lastVisibleLine = (__bridge CTLineRef)[lines objectAtIndex:numberOfLinesToDraw - 1];
            NSRange lastLineRange = NSRangeFromCFRange(CTLineGetStringRange(lastVisibleLine));
            lastLineRange.length = attributedString.length - lastLineRange.location;

            NSAttributedString *lastLineAttrStr = [attributedString attributedSubstringFromRange:lastLineRange];

            CTLineTruncationType truncationType;
            switch (self.lineBreakMode) {
                case VELLineBreakModeLastLineMiddleTruncation:
                    truncationType = kCTLineTruncationMiddle;
                    break;
                case VELLineBreakModeTailTruncation:
                default:
                    truncationType = kCTLineTruncationEnd;
                    break;
            }

            CTLineRef lastLine = CTLineCreateWithAttributedString((__bridge CFAttributedStringRef)lastLineAttrStr);
            CTLineRef ellipsisLine = createEllipsisLineRefWithAttributesFromString(lastLineAttrStr);
            @onExit {
                CFRelease(ellipsisLine);   
            };

            // If we have some amount of text we need to be sure that at a
            // minimum, the first character is shown with the ellipsis.
            if (lastLineAttrStr.length > 0) {
                NSMutableAttributedString *oneCharacterString = [[lastLineAttrStr attributedSubstringFromRange:NSMakeRange(0, 1)] mutableCopy];
                NSAttributedString *ellipsisString = attributedEllipsisForString(oneCharacterString);
                [oneCharacterString appendAttributedString:ellipsisString];

                CTLineRef line = CTLineCreateWithAttributedString((__bridge CFAttributedStringRef)oneCharacterString);
                @onExit {
                    CFRelease(line);
                };

                // Make sure the drawing width at least fits the first character
                // and the ellipsis. Actually drawn text may still be clipped by
                // the layer.
                drawableWidth = fmax(drawableWidth, ceil(CTLineGetTypographicBounds(line, NULL, NULL, NULL)));
            }

            CTLineRef lineToDraw = CTLineCreateTruncatedLine(lastLine, drawableWidth, truncationType, ellipsisLine);

            if (!lineToDraw)
                lineToDraw = lastLine;
            else
                CFRelease(lastLine);

            // Remove extra lines that we won't be drawing
            [lines removeObjectsInRange:NSMakeRange(numberOfLinesToDraw, lines.count - numberOfLinesToDraw)];

            // Replace the last line with our truncated version (transferring
            // its ownership to ARC in the same step)
            [lines replaceObjectAtIndex:numberOfLinesToDraw - 1 withObject:(__bridge_transfer id)lineToDraw];
        }
    }

    // Draw all the visible lines
    CGFloat originY = 0.0f;
    for (NSUInteger i = 0; i < lines.count; i++) {
        CTLineRef aLine = (__bridge CTLineRef)[lines objectAtIndex:i];

        CGFloat ascent;
        CGFloat descent;
        CGFloat leading;
        CGFloat lineWidth = CTLineGetTypographicBounds(aLine, &ascent, &descent, &leading);

        originY += ceil(ascent);

        CGFloat widthOfPrintedGlyphs = lineWidth - CTLineGetTrailingWhitespaceWidth(aLine);
        CGFloat indentWidth = 0.0f;

        switch (self.textAlignment) {
            case VELTextAlignmentCenter:
                indentWidth = floor((drawableWidth - widthOfPrintedGlyphs)/2.0f);
                break;

            case VELTextAlignmentRight:
                // It is not appropriate to floor `indentWidth` when using `VELTextAlignmentRight`
                indentWidth = drawableWidth - widthOfPrintedGlyphs;
                break;

            default:
                ;
        }

        CGContextSetTextPosition(context, indentWidth, (self.bounds.size.height) - originY);
        if (i < lines.count - 1 && self.textAlignment == kCTJustifiedTextAlignment) {
            // Only create justified lines if we are NOT at the last line yet
            aLine = CTLineCreateJustifiedLine(aLine, 1.0f, drawableWidth);
            CTLineDraw(aLine, context);
            CFRelease(aLine);
        } else {
            CTLineDraw(aLine, context);
        }

        originY += descent + leading;
    }
}

- (CGSize)sizeThatFits:(CGSize)constraint {
    NSAttributedString *string = self.formattedText;
    if (!string)
        return CGSizeZero;

    NSArray *lines;

    // if one line, don't constrain the width (the text should be as wide as
    // necessary)
    NSUInteger maximumLines = self.numberOfLines;
    if (maximumLines == 1) {
        CTTypesetterRef typesetter = CTTypesetterCreateWithAttributedString((__bridge CFAttributedStringRef)string);
        @onExit {
            CFRelease(typesetter);
        };

        CTLineRef fullTextLine = CTTypesetterCreateLine(typesetter, CFRangeMake(0, [string length]));
        lines = [NSArray arrayWithObject:(__bridge_transfer id)fullTextLine];
    } else {
        lines = [self linesForAttributedString:string constrainedToWidth:constraint.width];
    }

    CGFloat height = 0.0f;
    CGFloat width = 0.0f;

    for (NSUInteger i = 0; i < lines.count; i++) {
        if (maximumLines > 0 && i >= maximumLines)
            break;

        CTLineRef aLine = (__bridge CTLineRef)[lines objectAtIndex:i];

        CGFloat ascent;
        CGFloat descent;
        CGFloat leading;
        width = fmax(width, CTLineGetTypographicBounds(aLine, &ascent, &descent, &leading));

        height += ceil(ascent + descent + leading);
    }

    return CGSizeMake(ceil(width), ceil(height));
}

#pragma mark Formatting

- (void)setAttribute:(NSString *)attributeName value:(id)value; {
    NSMutableAttributedString *attributedString = [[NSMutableAttributedString alloc] initWithString:VELLabelEmptyAttributedString];

    if (self.formattedText)
        [attributedString setAttributedString:self.formattedText];

    NSRange range = NSMakeRange(0, attributedString.length);

    if (value)
        [attributedString addAttribute:attributeName value:value range:range];
    else
        [attributedString removeAttribute:attributeName range:range];

    self.formattedText = attributedString;
}

- (void)setParagraphStyle {
    CTTextAlignment textAlignment = m_textAlignment;

    CTParagraphStyleSetting settings[] = {
        {
            .spec = kCTParagraphStyleSpecifierAlignment,
            .valueSize = sizeof(textAlignment),
            .value = &textAlignment
        }
    };

    CTParagraphStyleRef paragraphStyle = CTParagraphStyleCreate(settings, sizeof(settings) / sizeof(*settings));
    [self setAttribute:NSParagraphStyleAttributeName value:(__bridge_transfer id)paragraphStyle];
}

#pragma mark Alignment

VELTextAlignment VELTextAlignmentFromNSTextAlignment(NSTextAlignment alignment) {
    switch (alignment) {
        case NSCenterTextAlignment: return(VELTextAlignmentCenter);
        case NSRightTextAlignment: return(VELTextAlignmentRight);
        case NSJustifiedTextAlignment: return(VELTextAlignmentJustified);
        case NSNaturalTextAlignment: return(VELTextAlignmentNatural);
        case NSLeftTextAlignment:
        default:
            return(VELTextAlignmentLeft);
    }
}

NSTextAlignment NSTextAlignmentFromVELTextAlignment(VELTextAlignment alignment) {
    switch (alignment) {
        case VELTextAlignmentCenter: return(NSCenterTextAlignment);
        case VELTextAlignmentRight: return(NSRightTextAlignment);
        case VELTextAlignmentJustified: return(NSJustifiedTextAlignment);
        case VELTextAlignmentNatural: return(NSNaturalTextAlignment);
        case VELTextAlignmentLeft:
        default:
            return(NSLeftTextAlignment);
    }
}
@end
