Truncate the last line of multi-line NSTextField
Asked Answered
T

2

6

I'm trying to create a text field similar to Finder's file labels. I would like the last (second) line to be truncated in the middle.

I started with a multi-line NSTextField.

However, calling [self.cell setLineBreakMode:NSLineBreakByTruncatingMiddle]; results in a the text field showing only a single truncated line (no line breaks anymore).

Here is what it looks like in Finder:

Finder example

Thinia answered 21/6, 2012 at 13:3 Comment(0)
W
8

If you want to wrap text like finder labels, using two labels doesn't do you any good since you need to know what the maximum breakable amount of text is on the first line. Plus, if you're building something that will display a lot of items two labels will overburden the GUI needlessly.

Set your NSTextField.cell like this:

[captionLabel.cell setLineBreakMode: NSLineBreakByCharWrapping];

Then find the code for "NS(Attributed)String+Geometrics" (Google it, it's out there). You must #import "NS(Attributed)String+Geometrics.h" to measure text. It monkey patches NSString and NSAttributedString

I include the following code to wrap text exactly how Finder does in its captions. Using one label below the icon it assumes that, like Finder, there will be two lines of caption.

First this is how you will call the following code in your code:

NSString *caption = self.textInput.stringValue;
CGFloat w = self.captionLabel.bounds.size.width;
NSString *wrappedCaption = [self wrappedCaptionText:self.captionLabel.font caption:caption width:w];
self.captionLabel.stringValue = wrappedCaption ? [self middleTruncatedCaption:wrappedCaption withFont:self.captionLabel.font width:w] : caption;

Now for the main code:

#define SINGLE_LINE_HEIGHT 21

/*
    This is the way finder captions work - 

    1) see if the string needs wrapping at all
    2) if so find the maximum amount that will fit on the first line of the caption
    3) See if there is a (word)break character somewhere between the maximum that would fit on the first line and the begining of the string
    4) If there is a break character (working backwards) on the first line- insert a line break then return a string so that the truncation function can trunc the second line
*/

-(NSString *) wrappedCaptionText:(NSFont*) aFont caption:(NSString*)caption width:(CGFloat)captionWidth
{
    NSString *wrappedCaption = nil;

    //get the width for the text as if it was in a single line
    CGFloat widthOfText = [caption widthForHeight:SINGLE_LINE_HEIGHT font:aFont];

    //1) nothing to wrap
    if ( widthOfText <= captionWidth )
       return nil;

    //2) find the maximum amount that fits on the first line
    NSRange firstLineRange = [self getMaximumLengthOfFirstLineWithFont:aFont caption:caption width:captionWidth];

    //3) find the first breakable character on the first line looking backwards
    NSCharacterSet *notAlphaNums = [NSCharacterSet alphanumericCharacterSet].invertedSet;
    NSCharacterSet *whites = [NSCharacterSet whitespaceAndNewlineCharacterSet];

    NSRange range = [caption rangeOfCharacterFromSet:notAlphaNums options:NSBackwardsSearch range:firstLineRange];

    NSUInteger splitPos;
    if ( (range.length == 0) || (range.location < firstLineRange.length * 2 / 3) ) {
        // no break found or break is too (less than two thirds) far to the start of the text
        splitPos = firstLineRange.length;
    } else {
        splitPos = range.location+range.length;
    }

    //4) put a line break at the logical end of the first line
    wrappedCaption = [NSString stringWithFormat:@"%@\n%@",
                        [[caption substringToIndex:splitPos] stringByTrimmingCharactersInSet:whites],
                        [[caption substringFromIndex:splitPos] stringByTrimmingCharactersInSet:whites]];

    return  wrappedCaption;
}

/*
    Binary search is great..but when we split the caption in half, we dont have far to go usually
    Depends on the average length of text you are trying to wrap filenames are not usually that long
    compared to the captions that hold them...
 */

-(NSRange) getMaximumLengthOfFirstLineWithFont:(NSFont *)aFont caption:(NSString*)caption width:(CGFloat)captionWidth
{
    BOOL fits = NO;
    NSString *firstLine = nil;
    NSRange range;
    range.length = caption.length /2;
    range.location = 0;
    NSUInteger lastFailedLength = caption.length;
    NSUInteger lastSuccessLength = 0;
    int testCount = 0;
    NSUInteger initialLength = range.length;
    NSUInteger actualDistance = 0;

    while (!fits) {
        firstLine = [caption substringWithRange:range];

        fits = [firstLine widthForHeight:SINGLE_LINE_HEIGHT font:aFont] < captionWidth;

        testCount++;

        if ( !fits ) {
            lastFailedLength = range.length;
            range.length-= (lastFailedLength - lastSuccessLength) == 1? 1 : (lastFailedLength - lastSuccessLength)/2;
            continue;
        } else  {
            if ( range.length == lastFailedLength -1 ) {
                actualDistance = range.length - initialLength;
                #ifdef DEBUG
                    NSLog(@"# of tests:%d actualDistance:%lu iteration better? %@", testCount, (unsigned long)actualDistance, testCount > actualDistance ? @"YES" :@"NO");
                #endif
                break;
            } else {
                lastSuccessLength = range.length;
                range.length += (lastFailedLength-range.length) / 2;
                fits = NO;
                continue;
            }
        }
    }

    return range;
}

-(NSString *)middleTruncatedCaption:(NSString*)aCaption withFont:(NSFont*)aFont width:(CGFloat)captionWidth
{
    NSArray *components = [aCaption componentsSeparatedByString:@"\n"];
    NSString *secondLine = [components objectAtIndex:1];
    NSString *newCaption = aCaption;

    CGFloat widthOfText = [secondLine widthForHeight:SINGLE_LINE_HEIGHT font:aFont];
    if ( widthOfText > captionWidth ) {
        //ignore the fact that the length might be an odd/even number "..." will always truncate at least one character
        int middleChar = ((int)secondLine.length-1) / 2;

        NSString *newSecondLine = nil;
        NSString *leftSide = secondLine;
        NSString *rightSide = secondLine;        

        for (int i=1; i <= middleChar; i++) {
            leftSide = [secondLine substringToIndex:middleChar-i];
            rightSide = [secondLine substringFromIndex:middleChar+i];

            newSecondLine = [NSString stringWithFormat:@"%@…%@", leftSide, rightSide];

            widthOfText = [newSecondLine widthForHeight:SINGLE_LINE_HEIGHT font:aFont];

            if ( widthOfText <= captionWidth ) {
                newCaption = [NSString stringWithFormat:@"%@\n%@", [components objectAtIndex:0], newSecondLine];
                break;
            }
        }
    }

    return newCaption;
}

Cheers!

PS Tested in prototype works great probably has bugs...find them

Winterkill answered 30/7, 2012 at 12:45 Comment(3)
Ok, I give up--I can't get it to even compile. Maybe I'm using the wrong patch (I Googled and got it from code.google.com/p/lyricus/source/browse/trunk/… ) maybe I'm setting up my interface incorrectly, using the wrong objects or binding names since those weren't explained. Any chance you could provide a bit more detail, like the full contents of your .h and .m files and where exactly you are getting the NS(Attributed)String+Geometrics from?Grisette
That code on google code should work. Just add the .h and .m to your project and use the above code - To call the code (see first two lines of answer - the part that is probably tripping you up is that caption is an instance var and it is used by the methods internally, the reason for this is that the class I use represents an item in a finder like grid of which caption is an ivar. If you want to make a more generic utility out of it, create a utility class and pass in the caption.Winterkill
I have updated the code, fixing some issues: Now it splits properly at breakable text locations, which are now any non-letter/digit character, not just white space. I also changed it so that the caption text and the max width are now passed as arguments.Kleiman
H
0

I suspect there are two labels there. The top one contains the first 20 characters of a file name, and the second contains any overflow, truncated.

The length of the first label is probably restricted based on the user's font settings.

Haight answered 21/6, 2012 at 13:12 Comment(4)
That's one possibility. Then the magic question is how to determine the number of characters that fit on the first line...Thinia
Well, the simple approach is to measure the size of strings, each one character longer than the last, until it fills the space available--then break to the next line manually. That may be expensive though if done frequently. Have you looked at Core Text by any chance?Haight
I have used NSAttributedString size and boundingRectWithSize:options: in the past, but neither one gave exact results (i.e. the correct width that would actually be used by the text field).Thinia
-[NSAttributedString boundingRectWithSize:options:] uses different metrics. Try -[NSCell cellSizeForBounds:] instead.Haight

© 2022 - 2024 — McMap. All rights reserved.