I am using a NSRulerView in MacOS in order to display line numbers next to a NSTextView. Both views share the same font and the same font size, however while in NSTextView string rendering is automatically managed, in the NSRulerView I need to compute correct line number (and this part works fine) and then render the string inside a drawHashMarksAndLabelsInRect.
My issue is that I am unable to correctly align text between the two views. For some font it works fine while for other fonts there are visible differences.
The code I am actually using is:
#define BTF_RULER_WIDTH 40.0f
#define BTF_RULER_PADDING 5.0f
static inline void drawLineNumber(NSUInteger lineNumber, CGFloat y, NSDictionary *attributes, CGFloat ruleThickness) {
NSString *string = [[NSNumber numberWithUnsignedInteger:lineNumber] stringValue];
NSAttributedString *attString = [[NSAttributedString alloc] initWithString:string attributes:attributes];
NSUInteger x = ruleThickness - BTF_RULER_PADDING - attString.size.width;
[attString drawAtPoint:NSMakePoint(x, y)];
}
static inline NSUInteger countNewLines(NSString *s, NSUInteger location, NSUInteger length) {
CFStringInlineBuffer inlineBuffer;
CFStringInitInlineBuffer((__bridge CFStringRef)s, &inlineBuffer, CFRangeMake(location, length));
NSUInteger counter = 0;
for (CFIndex i=0; i < length; ++i) {
UniChar c = CFStringGetCharacterFromInlineBuffer(&inlineBuffer, i);
if (c == (UniChar)'\n') ++counter;
}
return counter;
}
@implementation BTFRulerView
- (instancetype)initWithBTFTextView:(BTFTextView *)textView {
self = [super initWithScrollView:textView.enclosingScrollView orientation:NSVerticalRuler];
if (self) {
self.clientView = textView;
// default settings
self.ruleThickness = BTF_RULER_WIDTH;
self.textColor = [NSColor grayColor];
}
return self;
}
- (void)drawHashMarksAndLabelsInRect:(NSRect)rect {
// do not use drawBackgroundInRect for background color otherwise a 1px right border with a different color appears
if (_backgroundColor) {
[_backgroundColor set];
[NSBezierPath fillRect:rect];
}
BTFTextView *textView = (BTFTextView *)self.clientView;
if (!textView) return;
NSLayoutManager *layoutManager = textView.layoutManager;
if (!layoutManager) return;
NSString *textString = textView.string;
if ((!textString) || (textString.length == 0)) return;
CGFloat insetHeight = textView.textContainerInset.height;
CGPoint relativePoint = [self convertPoint:NSZeroPoint fromView:textView];
NSDictionary *lineNumberAttributes = @{NSFontAttributeName: textView.font, NSForegroundColorAttributeName: _textColor};
NSRange visibleGlyphRange = [layoutManager glyphRangeForBoundingRect:textView.visibleRect inTextContainer:textView.textContainer];
NSUInteger firstVisibleGlyphCharacterIndex = [layoutManager characterIndexForGlyphAtIndex:visibleGlyphRange.location];
// line number for the first visible line
NSUInteger lineNumber = countNewLines(textString, 0, firstVisibleGlyphCharacterIndex)+1;
NSUInteger glyphIndexForStringLine = visibleGlyphRange.location;
// go through each line in the string
while (glyphIndexForStringLine < NSMaxRange(visibleGlyphRange)) {
// range of current line in the string
NSRange characterRangeForStringLine = [textString lineRangeForRange:NSMakeRange([layoutManager characterIndexForGlyphAtIndex:glyphIndexForStringLine], 0)];
NSRange glyphRangeForStringLine = [layoutManager glyphRangeForCharacterRange: characterRangeForStringLine actualCharacterRange:nil];
NSUInteger glyphIndexForGlyphLine = glyphIndexForStringLine;
NSUInteger glyphLineCount = 0;
while (glyphIndexForGlyphLine < NSMaxRange(glyphRangeForStringLine)) {
// check if the current line in the string spread across several lines of glyphs
NSRange effectiveRange = NSMakeRange(0, 0);
// range of current "line of glyphs". If a line is wrapped then it will have more than one "line of glyphs"
NSRect lineRect = [layoutManager lineFragmentRectForGlyphAtIndex:glyphIndexForGlyphLine effectiveRange:&effectiveRange withoutAdditionalLayout:YES];
// compute Y for line number
CGFloat y = NSMinY(lineRect) + relativePoint.y + insetHeight;
// draw line number only if string does not spread across several lines
if (glyphLineCount == 0) {
drawLineNumber(lineNumber, y, lineNumberAttributes, self.ruleThickness);
}
// move to next glyph line
++glyphLineCount;
glyphIndexForGlyphLine = NSMaxRange(effectiveRange);
}
glyphIndexForStringLine = NSMaxRange(glyphRangeForStringLine);
++lineNumber;
}
// draw line number for the extra line at the end of the text
if (layoutManager.extraLineFragmentTextContainer) {
CGFloat y = NSMinY(layoutManager.extraLineFragmentRect) + relativePoint.y + insetHeight;
drawLineNumber(lineNumber, y, lineNumberAttributes, self.ruleThickness);
}
}
I think that the issue is the y computation then passed to the drawLineNumber function. Any idea about how to correctly compute it?
I found a solution and I think it could be quite useful to others:
#define BTF_RULER_WIDTH 40.0f
#define BTF_RULER_PADDING 5.0f
static inline void drawLineNumberInRect(NSUInteger lineNumber, NSRect lineRect, NSDictionary *attributes, CGFloat ruleThickness) {
NSString *string = [[NSNumber numberWithUnsignedInteger:lineNumber] stringValue];
NSAttributedString *attString = [[NSAttributedString alloc] initWithString:string attributes:attributes];
NSUInteger x = ruleThickness - BTF_RULER_PADDING - attString.size.width;
// Offetting the drawing keeping into account the ascender (because we draw it without NSStringDrawingUsesLineFragmentOrigin)
NSFont *font = attributes[NSFontAttributeName];
lineRect.origin.x = x;
lineRect.origin.y += font.ascender;
[attString drawWithRect:lineRect options:0 context:nil];
}
static inline NSUInteger countNewLines(NSString *s, NSUInteger location, NSUInteger length) {
CFStringInlineBuffer inlineBuffer;
CFStringInitInlineBuffer((__bridge CFStringRef)s, &inlineBuffer, CFRangeMake(location, length));
NSUInteger counter = 0;
for (CFIndex i=0; i < length; ++i) {
UniChar c = CFStringGetCharacterFromInlineBuffer(&inlineBuffer, i);
if (c == (UniChar)'\n') ++counter;
}
return counter;
}
@implementation BTFRulerView
- (instancetype)initWithBTFTextView:(BTFTextView *)textView {
self = [super initWithScrollView:textView.enclosingScrollView orientation:NSVerticalRuler];
if (self) {
self.clientView = textView;
// default settings
self.ruleThickness = BTF_RULER_WIDTH;
self.textColor = [NSColor grayColor];
}
return self;
}
- (void)drawHashMarksAndLabelsInRect:(NSRect)rect {
// do not use drawBackgroundInRect for background color otherwise a 1px right border with a different color appears
if (_backgroundColor) {
[_backgroundColor set];
[NSBezierPath fillRect:rect];
}
BTFTextView *textView = (BTFTextView *)self.clientView;
if (!textView) return;
NSLayoutManager *layoutManager = textView.layoutManager;
if (!layoutManager) return;
NSString *textString = textView.string;
if ((!textString) || (textString.length == 0)) return;
CGFloat insetHeight = textView.textContainerInset.height;
CGPoint relativePoint = [self convertPoint:NSZeroPoint fromView:textView];
// Gettign text attributes from the textview
NSMutableDictionary *lineNumberAttributes = [[textView.textStorage attributesAtIndex:0 effectiveRange:NULL] mutableCopy];
lineNumberAttributes[NSForegroundColorAttributeName] = self.textColor;
NSRange visibleGlyphRange = [layoutManager glyphRangeForBoundingRect:textView.visibleRect inTextContainer:textView.textContainer];
NSUInteger firstVisibleGlyphCharacterIndex = [layoutManager characterIndexForGlyphAtIndex:visibleGlyphRange.location];
// line number for the first visible line
NSUInteger lineNumber = countNewLines(textString, 0, firstVisibleGlyphCharacterIndex)+1;
NSUInteger glyphIndexForStringLine = visibleGlyphRange.location;
// go through each line in the string
while (glyphIndexForStringLine < NSMaxRange(visibleGlyphRange)) {
// range of current line in the string
NSRange characterRangeForStringLine = [textString lineRangeForRange:NSMakeRange([layoutManager characterIndexForGlyphAtIndex:glyphIndexForStringLine], 0)];
NSRange glyphRangeForStringLine = [layoutManager glyphRangeForCharacterRange: characterRangeForStringLine actualCharacterRange:nil];
NSUInteger glyphIndexForGlyphLine = glyphIndexForStringLine;
NSUInteger glyphLineCount = 0;
while (glyphIndexForGlyphLine < NSMaxRange(glyphRangeForStringLine)) {
// check if the current line in the string spread across several lines of glyphs
NSRange effectiveRange = NSMakeRange(0, 0);
// range of current "line of glyphs". If a line is wrapped then it will have more than one "line of glyphs"
NSRect lineRect = [layoutManager lineFragmentRectForGlyphAtIndex:glyphIndexForGlyphLine effectiveRange:&effectiveRange withoutAdditionalLayout:YES];
// compute Y for line number
CGFloat y = ceil(NSMinY(lineRect) + relativePoint.y + insetHeight);
lineRect.origin.y = y;
// draw line number only if string does not spread across several lines
if (glyphLineCount == 0) {
drawLineNumberInRect(lineNumber, lineRect, lineNumberAttributes, self.ruleThickness);
}
// move to next glyph line
++glyphLineCount;
glyphIndexForGlyphLine = NSMaxRange(effectiveRange);
}
glyphIndexForStringLine = NSMaxRange(glyphRangeForStringLine);
++lineNumber;
}
// draw line number for the extra line at the end of the text
if (layoutManager.extraLineFragmentTextContainer) {
NSRect lineRect = layoutManager.extraLineFragmentRect;
CGFloat y = ceil(NSMinY(lineRect) + relativePoint.y + insetHeight);
lineRect.origin.y = y;
drawLineNumberInRect(lineNumber, lineRect, lineNumberAttributes, self.ruleThickness);
}
}
I use drawWithRect instead of drawAtPoint and I use the attributes directly from the connected textView.
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With