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 LineNumberItem
s for the text lines that are visible, the painting speed remains high even for large pieces of text with complex layout.
Using the Code
The download ZIP file contains the VB.NET solution folder. All of the code for the LineNumber
s 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:
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 string
s 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.
If zLineNumbers_Show = True Then
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
zTextSize = e.Graphics.MeasureString(zTextToShow, Me.Font, zPoint, zSF)
zItemClipRectangle = New Rectangle(zPoint, zTextSize.ToSize)
If zLineNumbers_ClipByItemRectangle = True Then
zItemClipRectangle.Intersect(zLNIs(zA).Rectangle)
e.Graphics.SetClip(zItemClipRectangle)
End If
e.Graphics.DrawString(zTextToShow, Me.Font, zBrush, zPoint, zSF)
e.Graphics.ResetClip()
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))
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)
zGP_BorderLines.AddLines(zBorderLines_Points)
zGP_BorderLines.CloseFigure()
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:
If zSeeThroughMode = True Then
zRegion.MakeEmpty()
zRegion.Union(zGP_BorderLines)
zRegion.Union(zGP_MarginLines)
zRegion.Union(zGP_GridLines)
zRegion.Union(zGP_LineNumbers)
End If
If zRegion.GetBounds(e.Graphics).IsEmpty = True Then
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