Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles
(untagged)

LineNumbers for the RichTextBox

0.00/5 (No votes)
13 Apr 2019 3  
LineNumbers that dock to a RichTextBox or show as an overlay on top of it

Screenshot - linenumbers_for_rtb_examples.jpg

Introduction

Although there are already LineNumbering controls around, I decided to code one that gives the user a lot of freedom to create an individual look, whilst still handling the RichTextBox's dynamic content correctly. There is also a SeeThroughMode that allows the LineNumbers to be displayed as an overlay on top of the RichTextBox itself. Word wrapping and differences in line heights are all properly considered and, as the control only paints LineNumberItems for the text lines that are visible, the painting speed remains high even for large pieces of text with complex layout.

Screenshot - linenumbers_for_rtb_overlay.jpg

Using the Code

The download ZIP file contains the VB.NET solution folder. All of the code for the LineNumbers control is in the LineNumbers_For_RichTextBox class-code. Use the Solution Explorer to find it once you've opened the LineNumbers.sln project file. Make sure you build the project before opening the form or you'll get an error message. If that happens, close the Form's design tab and rebuild the solution. All of the code is provided "as is," with no rights nor liabilities attached. This means that you can use and change it as you please, at your own risk.

Copy the class-code into your project, and build or rebuild your application/solution. The LineNumbers control should then be available in your Toolbox. Once you've added the LineNumbers control to your form, you'll notice that it displays a vertical reminder message: you need to set the ParentRichTextBox property first so that it knows which RTB to show the LineNumbers for. Once it's set, the LineNumbers control will dock to the left side of the RTB -- controlled by the DockSide property -- and will either start showing the line numbers if there is already text in the RTB, or another reminder that shows which RTB it is connected to.

Available Properties

You can use the following elements to customize the look of the LineNumbers. All lines can have their Color, LineStyle (dot, dash, solid, etc.) and LineThickness changed. This LineNumbers control inherits from the basic Control class, so a BackgroundImage can also be set.

BorderLines

Element that defines the border around the whole control.

GridLines

Element that defines a horizontal divider line across the top of each LineNumber's item-area.

MarginLines

Element that defines a border line that can appear on the left, right, or both vertical sides of the control.

BackgroundGradient

Each LineNumber's item-area can have a gradient that softly blends two colors, named alpha and beta color in the public properties and Start/EndColor in the code. All colors can be transparent and you can also specify the gradient's direction, i.e., horizontal, vertical, forward/backward-diagonal. It's drawn via a Drawing2D.LinearGradientBrush. See the code snippet below:

' --- BackgroundGradient
If zGradient_Show = True Then
   zLGB = New Drawing2D.LinearGradientBrush(zLNIs(zA).Rectangle, _
              zGradient_StartColor, zGradient_EndColor, zGradient_Direction)
   e.Graphics.FillRectangle(zLGB, zLNIs(zA).Rectangle)
End If

LineNumbers

The LineNumbers' color and font is set via the normal ForeColor and Font properties, but there are also extra properties available to change their look and behavior:

  • LineNrs_Alignment: by which you can set the alignment point (TopLeft, TopCenter, TopRight, ...) for the LineNumber so that the number is drawn relative to that corner/center-point of its item-area. This is the same as the TextAlign property on a regular Label.
  • LineNrs_LeadingZeroes: pads the LineNumber with leading zeroes, based on the total amount of text lines in the RichTextBox.
  • LineNrs_AsHexadecimal: shows the LineNumbers as hexadecimal values (i.e., no leading zeroes in that case).
  • LineNrs_Anti-Alias: some fonts look better when the edges of the text-characters are slightly blended with the background. However, other fonts may look crisper without that softening, especially small pixel fonts.
  • LineNrs_ClippedByItemRectangle: if the LineNumbers are using a large font, they may spill out of their own item-area. This can sometimes give cool effects in combination with a partially transparent BackgroundGradient. This option allows you to clip the LineNumbers so that they only appear inside their own area.
  • LineNrs_Offset: although the alignment will take care of the LineNumber placement, this property allows you to manually fine-tune the LineNumber's position. Use negative values for offsets towards the TopLeft, and positive values to shift the position of the LineNumbers towards the BottomRight.

LineNumbers_For_RichTextBox

The behavior of the LineNumbers_For_RichTextBox control is governed by these properties:

  • ParentRichTextBox: This needs to be set first, as it allows you to point to the RichTextBox control for which the LineNumbers will be displayed. In design mode, a vertical reminder message will show up when the parent RTB is not set, or when the RTB has no text in it yet.
  • _SeeThroughMode_: The LineNumbers control can either be displayed next to its parent RichTextBox, or it can be displayed as an overlay on top of the RTB. The empty parts of the LineNumbers are then both see-through and click-through, so you can still use the RTB underneath.
  • AutoSizing: When active, auto-sizing will automatically adjust the width and position of the LineNumbers control as needed in order to make sure that the LineNumbers remain visible.
  • DockSide: You can use this to dock the LineNumbers to the left or right side of the parent RTB, or to lock the height to that of the RTB. When set to none, you can position the LineNumbers control freely like any other. The standard Dock will override the DockSide behavior, though.

Points of Interest

Although this is a pretty straightforward control designed to do just one thing, there were a few problems that needed some attention to get the control working at a good speed. The central Sub, which is Update_VisibleLineNumberItems() takes care of several of them. The rest of the work is mostly being done by the overridden OnPaint sub.

Lining Up the LineNumbers and RTB Text Lines

The RichTextBox has an easy GetPositionFromCharIndex() method that computes the position of a given text character -- identified by its index within the full text -- but that position point is in client-coordinates. So, at the start of the Update_VisibleLineNumberItems(), you can see some conversions to screen coordinates and back, to determine where the RTB's (0,0) origin point is in the LineNumbers control. Also, there is an additional check to find the control's (0,0) origin point within the parent RTB because the LineNumber control's Top may be positioned lower on the form than the RTB. That would make a difference in the computation of which text lines should get a LineNumberItem drawn for them, as only visible LineNumberItems should matter, to keep things speedy. The Update_VisibleLineNumberItems() sub basically builds a list, named zLNIs, of only the visible LineNumberItems. Each LineNumberItem (which is a Structure Update B: this is now a nested class) holds a LineNumber and a rectangle that marks the LineNumber's item-area.

WordWrapping and LineHeight

The main problem was the fact that when word wrapping splits a text line into multiple lines, those new text lines spill into the RichTextBox's Lines collection -- this happens on a regular TextBox, as well -- without actually adding items to the collection. For example, an RTB with 5 real text lines and word wrapping disabled will have a correct Lines collection of 5 items where each item is a real text line. But when word wrapping is enabled and happens to wrap the first real text line into 2 lines, then the Lines collection will still have 5 items, but item2 will be the word-wrapped second half of the first real text line. To counter that peculiar behavior, the LineNumbers control needs to create its own Lines collection, one that isn't affected by the word wrapping and the real text lines. This is the zSplit list of strings in the Update_VisibleLineNumberItems() sub. The line-height (i.e., the height of the LineNumberItem's rectangle) will be computed by comparing the Y-coordinate of each real text line with that of the next real line. The GetPositionFromCharIndex() method will give us the Y-coordinates, but the char index of the first character of each visible text line needs to be known.

Computing which LineNumbers are Visible

The control needs to find out which text lines in the RTB need to have a LineNumberItem drawn for them. Only visible items should be drawn to keep the painting speed high. The initial value of the zStartIndex variable, which is the char index of the first (fully or partially) visible text character will be computed by the FindStartIndex() sub. It's a recursive sub (i.e., one that calls itself) that basically looks for a text character that has a Y-coordinate closest to 0 or closest to the target value. The code comments will explain how it's done exactly.

The Painting of the LineNumbers (Just the Numbers)

Here's a code-snippet that shows the painting of the LineNumbers in the overridden OnPaint sub. The large TextAlignment computations that determine zPoint are left out, though. You can see how the text clipping is done by using the Graphics.SetClip method to temporarily restrict the drawing area. Also notice that a rectangle, zItemClipRectangle, based on the LineNumber's text-dimensions (clipped or not) is added to the zGP_LineNumbers object. This is a GraphicsPath object that will be used in SeeThroughMode. More on that is to be found in the next article section.

' --- LineNumbers
If zLineNumbers_Show = True Then
    '   TextFormatting
    If zLineNumbers_ShowLeadingZeroes = True Then
        zTextToShow = IIf(zLineNumbers_ShowAsHexadecimal, _
            zLNIs(zA).LineNumber.ToString("X"), _
            zLNIs(zA).LineNumber.ToString(zLineNumbers_Format))
    Else
        zTextToShow = IIf(zLineNumbers_ShowAsHexadecimal, _
            zLNIs(zA).LineNumber.ToString("X"), _
            zLNIs(zA).LineNumber.ToString)
    End If
    '   TextSizing
    zTextSize = e.Graphics.MeasureString(zTextToShow, Me.Font, zPoint, zSF)

    ' ==TextAlignment computation here (large Select Case to build zPoint)==
    
    '   TextClipping
    zItemClipRectangle = New Rectangle(zPoint, zTextSize.ToSize)
    If zLineNumbers_ClipByItemRectangle = True Then
        '   If selected, the text will be clipped so that it doesn't spill out
        '   of its own LineNumberItem-area. Only the part of the text inside 
        '   the LineNumberItem.Rectangle should be visible, so intersect with 
        '   the ItemRectangle.
        '   The SetClip method temporary restricts the drawing area of the 
        '   control for whatever is drawn next.
        zItemClipRectangle.Intersect(zLNIs(zA).Rectangle)
        e.Graphics.SetClip(zItemClipRectangle)
    End If
   
    '   TextDrawing
    e.Graphics.DrawString(zTextToShow, Me.Font, zBrush, zPoint, zSF)
    e.Graphics.ResetClip()   
   
    '   The GraphicsPath for the LineNumber is just a rectangle behind the 
    '   text, to keep the paintingspeed high and avoid ugly artifacts.
    zGP_LineNumbers.AddRectangle(zItemClipRectangle)
    zGP_LineNumbers.CloseFigure()
End If

SeeThroughMode

I can imagine people being interested in this, as it's a little more advanced than the simple painting of lines and rectangles. So, here's some information on how it's done: it works by using a Drawing2D.GraphicsPath object, which is similar to the more regularly used Graphics type. However, when you paint something on a GraphicsPath, you're basically painting which pixels will be see-through or not when that GraphicsPath -- or a combination of several GraphicsPaths, in this case -- is set as the Region of the control. In other words, you're creating a custom outline for the control so that you can make the control any shape you like, even with holes in it if needed.

I'm doing the painting on the GraphicsPaths at the same time as the regular painting in the overridden OnPaint sub. This is because the lines and rectangle figures are being computed anyway, so I might as well use them twice. The code-snippet below shows this clearly: the same border lines that are drawn on the regular Graphics (e.Graphics.DrawLines ...) are also drawn onto a GraphicsPath (zGP_BorderLines.AddLines...):

Dim zGP_BorderLines As New Drawing2D.GraphicsPath(Drawing2D.FillMode.Winding)

Dim zP_Left As New Point(Math.Floor(zBorderLines_Thickness / 2), _
    Math.Floor(zBorderLines_Thickness / 2))
Dim zP_Right As New Point(
    Me.Width - Math.Ceiling(zBorderLines_Thickness / 2), _
    Me.Height - Math.Ceiling(zBorderLines_Thickness / 2))

' --- BorderLines 
Dim zBorderLines_Points() As Point = { _
    New Point(zP_Left.X, zP_Left.Y), _
    New Point(zP_Right.X, zP_Left.Y), _
    New Point(zP_Right.X, zP_Right.Y), _
    New Point(zP_Left.X, zP_Right.Y), _
    New Point(zP_Left.X, zP_Left.Y)}
If zBorderLines_Show = True Then
   zPen = New Pen(zBorderLines_Color, zBorderLines_Thickness)
   zPen.DashStyle = zBorderLines_Style
   e.Graphics.DrawLines(zPen, zBorderLines_Points)

   '   And the same shape is added to the border's GraphicsPath
   zGP_BorderLines.AddLines(zBorderLines_Points)
   zGP_BorderLines.CloseFigure()

   '   BorderThickness and Style for SeeThroughMode
   zPen.DashStyle = Drawing2D.DashStyle.Solid
   zGP_BorderLines.Widen(zPen)
End If

At the end of the OnPaint sub, the control simply checks whether zSeeThroughMode is active. If it is, then the different GraphicsPaths (named zGP_...) are combined and form the control's Region after an extra check is done, to make sure the control won't be empty:

' --- SeeThroughMode
'   combine all the GraphicsPaths (= zGP_... ) and set them as the Region 
If zSeeThroughMode = True Then
    zRegion.MakeEmpty()
    zRegion.Union(zGP_BorderLines)
    zRegion.Union(zGP_MarginLines)
    zRegion.Union(zGP_GridLines)
    zRegion.Union(zGP_LineNumbers)
End If

' --- Region
If zRegion.GetBounds(e.Graphics).IsEmpty = True Then
    '   Note: If the control is in a condition that would show it as empty, 
    '   then a border-region is still drawn regardless of it's borders' 
    '   on/off state. This is added to make sure that the bounds of the 
    '   control are never lost (it would remain empty if this was not done).
    zGP_BorderLines.AddLines(zBorderLines_Points)
    zGP_BorderLines.CloseFigure()
    zPen = New Pen(zBorderLines_Color, 1)
    zPen.DashStyle = Drawing2D.DashStyle.Solid
    zGP_BorderLines.Widen(zPen)

    zRegion = New Region(zGP_BorderLines)
End If
Me.Region = zRegion

Updates

Fixed:

  • (A) When the first LineNumberItem had a negative Y-coordinate, the bottom line of the rectangle for the GridLines' GraphicsPath would show inside the control. Offsetting by -zLNIs(0).Rectangle.Y has fixed this.

Improved:

  • (B)Performance has been doubled by increasing the efficiency of the Update_VisibleLineNumberItems() method. This was achieved by halving the number of calls to the RTB's .GetPositionFromChar() method, which becomes slower as the number of text lines grows.
  • (B) Scrolling of large documents now has a time-based cutoff for computing LineNumberItems so that scrolling remains smooth.

The End

That's it, I hope you like this LineNumbers_For_RichTextBox control and find it useful in your own projects. Enjoy! 

History

  • 31st May, 2007: Article edited and moved to the main CodeProject.com article base
  • 12th April, 2007: Updated
  • 5th April, 2007: Original version posted

License

This article has no explicit license attached to it but may contain usage terms in the article text or the download files themselves. If in doubt please contact the author via the discussion board below.

A list of licenses authors might use can be found here