Displaying Line Numbers with NSTextView

Yes, it’s free code time again. I’ve been neglecting the blog for some time so hopefully this will make up for it. Think of it as that conciliatory heart-shaped box of chocolates used as a sorry way to make up for forgetting about your birthday, after which, I go back to my old ways of sitting on the couch all day watching sports, ignoring you.

In version 2.2 of Hazel, I added mini AppleScript and shell script editors so that people could enter scripts inline without having to go to another program and saving it to an external file. I’ll admit, I didn’t set out to make an uber-editor since it was intended for small scripts. Nonetheless, a user recently pointed out that when a line wraps, it’s hard to tell if it’s a continuation of the previous line or a new one. One of his suggestions was putting line numbers in the left gutter. If you don’t know what I’m talking about, look at TextMate (the example he cited) or XCode (you need to turn it on in preferences). I thought it might be overkill for a script editor that will mostly be used for scripts less than ten lines long. I’m instead considering doing an indented margin for continuation lines. Less visual clutter and addresses the problem at hand.

Nonetheless, I was curious about implementing line numbers. Poking around, I found some tips on how to do it but it seemed like there were odd problems implying it wasn’t as straightforward as one would think. So, snatching some free time in between other things, I decided to tackle the problem.

I looked into subclassing NSRulerView. The problem is that NSRulerView assumes a linear and regular scale. Now, to make it clear, I am talking about numbering logical lines, not visual ones. If a line wraps, it still counts as one line even if it takes two or more visually. The scale is solely dependent on the layout of the text and can’t be computed from an equation. Despite these limitations, I went ahead and subclassed NSRulerView. If anything, NSScrollView knows how to tile it.

I had this notion that NSRulerView was a view that synced its dimensions with the document view of the scrollview. With a vertical ruler, I assumed it would be as tall as the document and the scroll view just scrolls it in tandem with the document. Not so. It’s only as tall as the scrollview. That means you have to translate the scale depending on the clipview’s bounds.

I added some marker support via an NSRulerMarker subclass that knows about line numbers. The line number view will draw the markers underneath the labels a la XCode (with the text inversed to white). The sample project uses another subclass which will toggle markers on mouse click. While NSRulerView usually delegates this to its client view it made more sense to just do it in a subclass of NSRulerView. You have to subclass something to get it to work and it made more sense to subclass the ruler view since the code to handle markers never interacts with anything in the client view anyways. Personally, I find it an odd design on Apple’s part and would have preferred a regular delegate.

The project is linked below. The main classes are NoodleLineNumberView and NoodleLineNumberMarker. Some notes:

  • To integrate: just create the line number view and set it as the vertical ruler. Make sure the document view of the scrollview is an NSTextView or subclass. Depending on the order of operations, you may have to set the client view of the ruler to the text view.
  • The view will expand it’s width to accommodate the widths of the labels as needed.
  • The included subclass (MarkerLineNumberView) shows how to deal with markers. It also shows how to use an NSCustomImageRep to do the drawing. This allows you to reset the size of the image and have the drawing adjust as needed (this happens if the line number view changes width because the line numbers gained an extra digit).
  • Note that markers are tied to numerical lines, not semantic ones. So, if you have a marker at line 50 and insert a new line at line 49, the marker will not shift to line 51 to point at the same line of text but will stay at line 50 pointing at whatever text is there now. Contrast with XCode where the markers move with insertions and deletions of lines (at least as best as it can). This is logic that you’ll have to supply yourself.

More details, including performance notes, can be found in the Read Me file included in the project.

I’m putting this out there because I’m probably not going to use it and it seems like a waste of some useful code. Also, my apologies to the user who asked for this feature. I feel like somewhat of a jerk going through the trouble of implementing the feature and not including it. It was more of a fun exercise on my part but I still feel it’s not suitable for Hazel. That said, I may consider adding it and having it available via a hidden default setting. Votes for or against are welcome.

In the meantime, you can use the code however you want. MIT license applies. Please send me any bug reports, suggestions and feedback.

Enjoy.

Download Line View Test.zip (version 0.4.1)

Update (Oct. 6, 2008): Uploaded version 0.3. Fixes bugs found by Jonathan Mitchell (see comments on this post). Also made line calculations lazy for better performance.

Update (Oct. 10, 2008): Uploaded version 0.4. Fixes bugs mentioned in the comments as well as adds methods to set different colors. There is a display bug that happens when linking against/running on 10.4. See the Read Me for details.

Update (Oct. 13, 2008): Uploaded version 0.4.1. Figured out the 10.4 display bug. Apparently, NSRulerView’s setRuleThickness: method doesn’t like non-integral values. Rounding up solves the problem. Thanks to this page for identifying the problem.

Update (Sep. 29, 2009): I have included this class in my NoodleKit repository so you should check there for future updates.

Category: Cocoa, Downloads, OS X, Programming 23 comments »

23 Responses to “Displaying Line Numbers with NSTextView”

  1. Jonathan Mitchell

    Hi Paul

    This is a great bit of work. I have looked for something open source of this nature before and found nothing. It’s not a trivial piece of work by any means.

    The code works well. My only issue is when loading text into the NSTextView when it seems necessary to manually call [_lineNumberView textDidChange:nil] as the -textDidChange: notification does not get sent when setting text thus [[textView textStorage] setAttributedString:source].

    My other observation is that in my case the line numbers and the actual text are not aligned as accurately as they are in the demo project. In this case my text view is OSAScriptView, a subclass of NSTextView. If you want a screenshot I can send one in.

    An excellent and very welcome contribution.

    Thanks

    Jonathan

  2. mr_noodle

    Thanks for the report. You’re right about the text notifications. It seems better to listen for NSTextStorage’s NSTextStorageDidProcessEditingNotification instead. I’ll change that and re-upload it in the next version.

    Odd behavior with OSAScriptView, especially considering it lines up correctly with the same font in an NSTextView. I’ll take a look at it and hopefully can figure out a fix in the next version as well.

  3. mr_noodle

    Just posted an update. It seemed I wasn’t taking the text container inset into account which resulted in the misalignment with OSAScriptView. Let me know how it works for you.

  4. Jonathan Mitchell

    Thanks for the rapid update. A few points here:

    1. The OSAScriptView issue is resolved.

    2. If the text scrollview is embedded in a primary style NSBox then there are alignment issues. A custom box is fine.

    3. In general (and this is clearer if you up the font size) the line number is placed towards the top of the line’s bounding rectangle. Even at small font sizes it is slightly disconcerting. Xcode vertically aligns the number in the center of the line. It looks better (IMHO) and works as the font size increases.

    4. A textured window produces a very dark ruler background. Rulers in NSTextView behave the same. See this link for the low-down: http://www.cocoabuilder.com/archive/message/cocoa/2008/1/31/197690

    In regard to this I would tentatively suggest three additional properties for LineNumberView:

    – (NSColor *)foregroundColor;
    – (NSColor *)backgroundColor;
    – (BOOL)drawsBackground;

    The backgroundColor can be referenced in – (void)drawBackgroundInRect:(NSRect)rect and the foreground in – (NSDictionary *)textAttributes;

    // private override
    – (void)drawBackgroundInRect:(NSRect)rect
    {
    if ([self drawsBackground]) {
    [[self backgroundColor] set];
    [NSBezierPath fillRect: rect];
    }
    }

  5. stephane

    Very interesting code. There are 2 remaining issues though:

    – typo in the About box: “The view will expand it’s width” it’s -> its

    – if you enter 100 lines, the gutter correctly expands but if you add or remove lines then you may end up seeing 2 vertical lines on the right of the gutter or just 1.

  6. mr_noodle

    Jonathan: I’m not seeing #2 & #3. Could you provide screenshots or more details on the configuration? As for #4, I don’t want to use undocumented API. I could just override drawRect and draw the background myself. I’ll play with it.

    Stephane: Thanks for the heads up. Fixed both the typo and the bug (the bug crept in with the last update).

    I’m a bit busy at the moment but I’ll do another rev in the next couple days.

  7. Jonathan Mitchell

    #2. I deleted the NSBox configuration and recreated it, only for the issue to promptly evaporate! So there may or may not be something there.

    #3. This effect is slight I think though visible. Configure two NSTextViews side by side. Call setFont:[NSFont userFixedPitchFontOfSize:[NSFont smallSystemFontSize]] on one, leave the other as is. Use { as a convenient test character – increase the font. Probably just a result of the different font metrics.
    Or maybe I am just seeing things.

    As for #4, direct draw rect approach seems to work:

    – (void)drawRect:(NSRect)rect
    {
    [[NSColor colorWithCalibratedWhite: 0.85 alpha: 1.0] set];
    [NSBezierPath fillRect: rect];
    [self drawHashMarksAndLabelsInRect:rect];
    }

  8. mr_noodle

    Just uploaded a new version with some fixes. Unfortunately, there’s an existing display bug with 10.4 which I’ll have to investigate more later.

    Jonathan: I changed the default font used. The misalignment is a font metrics issue but it seems that using Lucida Grande makes for better alignment and is what XCode uses.

  9. Jonathan Mitchell

    Thanks for the 0.4.1 update. Issues resolved. Looking Good.

  10. Will

    Thanks for posting this code, it was exactly what I was looking for and works great even with large amounts of text (I’m using it for a IDE type project). However, I think I might have found a bug, although it might be a side effect of how you calculate the position for a new marker when the user clicks on the view. If you scroll quickly through the document with a large number of lines, then click to add a new marker, it won’t be added in the correct location. Successive clicks get the marker closer and closer to where it should be and eventually it shows up in the proper place, while all the previously placed markers remain. I’m not sure how to go about fixing this, and any suggestions would be welcome. Thanks again.

  11. mr_noodle

    Will: I’m not seeing this. Can you give me a specific recipe or even better, post a movie of it happening?

    One thing I did notice is that calculation of the line number from the mouse coordinates could be quicker as it does seem a bit pokey with huge files. But other than that, I’m not seeing any misplaced markers.

  12. Will

    Alright, I put together a small video demonstrating the behavior inside my IDE application. The problem must have something to do with the way I’m using it in my app, because I was unable to replicate the problem in your sample app while viewing the same file. I should note that the only difference in the layout of the text view (just being a vertical split view), otherwise your code hasn’t been altered, which is why I was surprised to see the issue in the first place. I uploaded the video to youtube, you can find it at http://www.youtube.com/watch?v=HbIIctvGfWo and hopefully it will be done processing by the time you get a chance to look at it. Youtube probably isn’t the best place, but I couldn’t think of anything else, if you have another way I can get the video to you, just let me know. I appreciate you taking a look and if there’s anything else I can do to help diagnose the problem just let me know.

  13. Will

    Sorry, the quality on the youtube video is horrible. The video did show the line numbers that the lineNumberForLocation: method found each time the mouse was clicked inside the ruler view, which I thought would be helpful, but you can’t really see anything with the horrible youtube quality. I’m open to suggestions for getting it to you or posting it to an alternative site.

  14. Bruce

    I wanted to add a few methods to your wonderful code that make it easier to navigate through the text. The 5 methods are as follows, and they are to be added to NoodeLineNumberView.m:

    1) physicalLineAtInsertion: returns the physical line in the text document where the insertion point is positioned in the textView.

    2) logicalLineAtInsertion: returns the logical line in the textView where the insertion point is positioned in the text view

    3) indexOfPhysicalLine: returns the character index of line corresponding to line number.

    4) logicalLineIndexAtPhysicalCharacterIndex: returns the index of the wrapped line of text in the text view corresponding to the physical line index obtained from 3.

    5) gotoLine: uses 3 & 4 to position the insertion point in the text view at the beginning of

    There may be a more optimal way of doing things, but these work on small texts nicely…

    Thank you for your line number contribution….

    Bruce

    Code:
    ———————————————————–
    -(unsigned) logicalLineAtInsertion
    {
    NSTextView* tview = [self clientView];
    NSLayoutManager *layoutManager = [tview layoutManager];
    NSRange lineRange;
    unsigned numberOfLines, index, numberOfGlyphs = NSMaxRange([layoutManager glyphRangeForCharacterRange:NSMakeRange(0,[tview selectedRange].location) actualCharacterRange:NULL]);
    for (numberOfLines = 0, index = 0; index < numberOfGlyphs; numberOfLines++){
    (void) [layoutManager lineFragmentRectForGlyphAtIndex:index
    effectiveRange:&lineRange];
    index = NSMaxRange(lineRange);
    }

    return numberOfLines;

    }

    -(unsigned) physicalLineAtInsertion
    {
    NSTextView* tview = [self clientView];
    unsigned physical = [self lineNumberForCharacterIndex:[tview selectedRange].location inText:[tview string]] + 1;
    return physical;

    }

    -(unsigned) indexOfPhysicalLine:(unsigned) linenumber
    {

    if (linenumber == 1) return 0;
    NSString *string = [[self clientView] string];
    unsigned numberOfLines, index, stringLength = [string length];
    for (index = 0, numberOfLines = 0; index < stringLength; numberOfLines++)
    {
    index = NSMaxRange([string lineRangeForRange:NSMakeRange(index, 0)]);
    if (numberOfLines == linenumber- 2) break;
    }

    return index;

    }

    -(unsigned) logicalLineIndexAtPhysicalCharacterIndex:(unsigned) physicalindex
    {
    NSTextView* tview = [self clientView];
    NSLayoutManager *layoutManager = [tview layoutManager];
    NSRange lineRange;
    unsigned numberOfLines, index, numberOfGlyphs = NSMaxRange([layoutManager glyphRangeForCharacterRange:NSMakeRange(0,physicalindex) actualCharacterRange:NULL]);
    for (numberOfLines = 0, index = 0; index < numberOfGlyphs; numberOfLines++){
    (void) [layoutManager lineFragmentRectForGlyphAtIndex:index
    effectiveRange:&lineRange];
    index = NSMaxRange(lineRange);
    }

    return index;

    }

    -(void) gotoLine:(unsigned) lineNumber
    {

    NSTextView* tview = [self clientView];
    unsigned insertionLocation = [self logicalLineIndexAtPhysicalCharacterIndex:[self indexOfPhysicalLine:lineNumber]];
    [tview setSelectedRange:NSMakeRange(insertionLocation,0)];
    [tview scrollRangeToVisible:NSMakeRange(insertionLocation,0)];
    }

  15. Travis

    Thanks for putting this code out there. I can’t believe how hard it is to add line numbers to an NSTextView. You’d think Apple would either put out example code or make a standard view that can be used.

    I used your code for a project I was working on and it has turned out great. One thing that I found out though is that your latest release (0.4.1) will not work on 64-bit machines. The problem is that you are using floats instead of CGFloat. This is mostly not an issue since it’ll get cast to the correct value, but since you are then using NSInvocation which sets arguments via addresses to memory, it gets a bit messy. The margin was not showing up when compiled as 64-bit. Once I changed the floats to CGFloats everything started working.

  16. Raj

    Thanks for posting this! I decided to improve the line numbering implementation in Taco HTML Edit, and this solution seems to work perfectly.

  17. mr_noodle

    Travis: A bit late (I’m pretty bad about revisiting old posts). If you haven’t already, there is a newer version with 64-bit support in the NoodleKit repository linked in the update above.

    Raj: I’m glad it’s being used in products out in the field. By the way, you did sign that damage waiver before shipping, right?

  18. John

    Hi and thanks for this code it is very nice to see how this done.

    I wonder if you have noticed that toggling the NSTextView’s ruler on and off with setRulerVisible does not work as expected. I don’t think it’s a bug in your code, I think it is weirdness in how the text view works with the scroll view.

    Try toggling the line numbers with e.g. [scriptView setRulerVisible:YES] and NO, respectively. You can turn the line numbers ruler off, but when you turn it back on again, you get *both* horizontal and vertical rulers. This happens even though the code tells the scrollview has no horizontal ruler: [scrollView setHasHorizontalRuler:NO].

    Any thoughts on this?

    Thanks again and hope you still watch this thread!

  19. Marcial CZ

    Thank you for this nice piece of code. I will use for an in-house IDE along whit Uli Kusterer’s syntax coloring. Final version will be shared of course.

  20. Sven

    Can you insert a TabStop for the View?

  21. Sven

    Find a way for TabStop:

    (Changes for file:NoodleLineNumberView.m)

    – (void)calculateLines
    {
    id view;

    view = [self clientView];

    if ([view isKindOfClass:[NSTextView class]])
    {
    NSUInteger charIndex, stringLength, lineEnd, contentEnd, count, lineIndex;
    NSString *text;
    CGFloat oldThickness, newThickness;

    // New NSTabStop start

    NSMutableParagraphStyle *style = [[NSMutableParagraphStyle alloc] init];
    [style setDefaultTabInterval:36.];
    [style setTabStops:[NSArray array]];
    [view setDefaultParagraphStyle:style];
    [view setTypingAttributes:[NSDictionary DictionaryWithObjectsAndKeys:_font,NSFontAttributeName,style,NSParagraphStyleAttributeName,nil]];

    // New NSTabStop end

  22. Kevin Packard

    Perfect – using for an in-house IDE. Beautiful piece of work. Thank you.

  23. SiliconTrip

    Hi there Noodlers,
    I’ve just downloaded this and integrated into my app, it needed a few minor changes for Xcode 10.1, I should probably have posted a patch. Anyway the app that I’m working on is here; http://github.com/silicontrip/pdftracer plug! plug! 😉 and also a compilable version of NoodleLineNumberMarker and NoodleLineNumberView for recent Xcode. Happy Hacking! — ST


Leave a Reply



Back to top