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

CodeBox 2: An Extended and Improved Version of the CodeBox with Line Numbers

0.00/5 (No votes)
10 Oct 2009 3  
A WPF textbox supporting line numbering, highlighting, underlines, and strikethroughs.

Introduction

This article presents an enhanced version of the CodeBox of my previous article. It now features line numbering and more efficient rendering. Only the visible text is now rendered. In addition to the decorations of word coloring, highlighting, strikethroughs, and underlining, there is now support for decoration schemes that serve as a base for further decorations. For instance, we can now have a C# decoration scheme and then add highlighting on top of that.

The sample application is a simple text editor that supports decoration schemes and text coloring. It also allows you to take snapshots of the displayed code as image files. I was looking to make a simple but at least somewhat useful app to show off what the improved CodeBox can do.

The Big Ideas

CodeBox is an enhanced version of the WPF textbox, allowing a greater degree of visual customization, while keeping as much of the original textbox functionality as possible. It comes from the following observations:

  • We can render over the surface of the textbox while keeping the rest of its functionality. This is covered in the first article.
  • We can create a hierarchy of classes representing the decorations, such as text coloration and highlighting. This too was covered in the previous article.
  • Decorations can be broken up into two types: ones that are intrinsic to the type of data involved (BaseDecorations) and decorations specific to the document.
  • The line numbers and other types of labels can be added by altering the ControlTemplate for the Codebox. This allows us to both have that standard text rendering area as well as an area for the line numbers.
  • We can also improve efficiency by only rendering the text that is currently visible in the Codebox. The previous version rendered all text up until the currently viewed text. This made it practical only for short documents.

Background

This CodeBox is a major revision and upgrade of the one presented in my CodeBox article. Although this version is much improved and definitely the one I would advise you to use for your purposes, reading the original and examining its code should make this one easier to understand.

In creating this control, I found that a significant amount of the documentation on the TextBox that I would have liked to read was nonexistent. Hopefully, this will cover that deficit. I will be using the following format:

Member Name MSDN Definition
Disassembled code from Reflector

Commentary

GetFirstVisibleLineIndex: Returns the line index for the first line that is currently visible in the text box.

public int GetFirstVisibleLineIndex()
{
    if (base.RenderScope == null)
    {
        return -1;
    }
    double lineHeight = this.GetLineHeight();
    return (int) Math.Floor((double) ((base.VerticalOffset / lineHeight) + 0.0001));
}

The first thing that we should notice is the line testing the RenderScope property. The Renderscope is set as part of the building of the Visual tree. As far as I have been able to ascertain, the RenderScope property will always be null in design mode. A Unit Test like the following will always fail:

[Test]
public void LineCount_Test()
{
    TextBox tx = new TextBox();
    tx.Text = "1\n2";
    tx.Height = 200;
    tx.Width = 200;
    Assert.AreEqual(2, tx.LineCount);
}

This and many variations will fail because the RenderScope is null. It is also worth noting that the private private GetLineheight method depends only on the font size and the font family. There is no parameter to represent leading.

LineCount: Gets the total number of lines in the text box.

[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public int LineCount
{
    get
    {
        if (base.RenderScope == null)
        {
            return -1;
        }
        return (this.GetLineIndexFromCharacterIndex
			(base.TextContainer.SymbolCount) + 1);
    }
}

The LineCount is simply the line at the end of the text. This is the number of lines which appear in the textbox, and thus changes with the textbox™s width. This method cannot be depended upon. Immediately after a change in the Text property, it will take the value 0. Be careful as this means that it cannot be called in response to the TextChanged event.

There is no built-in function for the Minimum Line Count, setting RexrWrapping to None should give the same results. The following function will work, and serves to illustrate the rules used to determine line numbers:

public int MinLineCount(TextBox tx)
{
    string str = tx.Text;
    // \r\n represents one, not two linebreaks
    str = str.Replace("\r\n", "\n"); //Make all line delimiters 1 character
    char[] chars = str.ToCharArray();
    int breakCount = 0;
    //lineBreaks are the list of special character that cause an additional line
    char[] lineBreaks = {  Convert.ToChar("\r"),  
                           Convert.ToChar("\n"),  
                           Convert.ToChar("\f"),  
                           Convert.ToChar("\v" )};
    for (int i = 0; i < str.Length; i++)
    {
        if (lineBreaks.Contains(chars[i]))
        {
            breakCount++;
        }
    }
    return breakCount + 1;
}

GetLastVisibleLineIndex: Returns the line index for the last line that is currently visible in the text box.

public int GetLastVisibleLineIndex()
{
    if (base.RenderScope == null)
    {
        return -1;
    }
    double extentHeight = 
      ((IScrollInfo) base.RenderScope).ExtentHeight;//Last line visible box
    if ((base.VerticalOffset + base.ViewportHeight) >= extentHeight)
    {
        return (this.LineCount - 1);
    }
    return (int) Math.Floor((double) (((base.VerticalOffset + 
            base.ViewportHeight) - 1.0) / this.GetLineHeight()));
}

GetLastVisibleLineIndex is rather undependable. After changes to text and especially key presses, it will take the value of -1. It is capable of failing even if LineCount > 0.

We see that there are two cases for the last visible line index. The last line of text could appear in the viewport in which the index is determined by the LineCount. Otherwise, it is computed from the Lineheight, VerticalOffset, and ViewportHeight. Note that this is the index of the last, at least partially visible, line, rather than the index of the last fully visible line.

GetRectFromCharacterIndex: Overloaded. Returns the bounding rectangle for the character at the specified character index.

public Rect GetRectFromCharacterIndex(int charIndex, bool trailingEdge)
{
    Rect rect;
    if ((charIndex < 0) || (charIndex > base.TextContainer.SymbolCount))
    {
        throw new ArgumentOutOfRangeException("charIndex");
    }
    TextPointer insertionPosition = base.TextContainer.CreatePointerAtOffset(charIndex, 
        LogicalDirection.Backward).GetInsertionPosition(LogicalDirection.Backward);
    if (trailingEdge && (charIndex < base.TextContainer.SymbolCount))
    {
        insertionPosition = 
          insertionPosition.GetNextInsertionPosition(LogicalDirection.Forward);
        Invariant.Assert(insertionPosition != null);
        insertionPosition = 
          insertionPosition.GetPositionAtOffset(0, LogicalDirection.Backward);
    }
    else
    {
        insertionPosition = 
          insertionPosition.GetPositionAtOffset(0, LogicalDirection.Forward);
    }
    this.GetRectangleFromTextPositionInternal(insertionPosition, true, out rect);
    return rect;
}

 public Rect GetRectFromCharacterIndex(int charIndex)
{
    return this.GetRectFromCharacterIndex(charIndex, false);
}

GetLineIndexFromCharacterIndex: Returns the zero-based line index for the line that contains the specified character index.

public int GetLineIndexFromCharacterIndex(int charIndex)
{
    if (base.RenderScope != null)
    {
        Rect rect;
        if ((charIndex < 0) || (charIndex > base.TextContainer.SymbolCount))
        {
            throw new ArgumentOutOfRangeException("charIndex");
        }
        TextPointer position = base.TextContainer.CreatePointerAtOffset(charIndex, 
                               LogicalDirection.Forward);
        if (this.GetRectangleFromTextPositionInternal(position, false, out rect))
        {
            rect.Y += base.VerticalOffset;
            return (int) ((rect.Top + (rect.Height / 2.0)) / this.GetLineHeight());
        }
    }
    return -1;
}

GetLineText: Returns the text that is currently displayed on the specified line.

public string GetLineText(int lineIndex)
{
    if (base.RenderScope == null)
    {
        return null;
    }
    if ((lineIndex < 0) || (lineIndex >= this.LineCount))
    {
        throw new ArgumentOutOfRangeException("lineIndex");
    }
    TextPointer startPositionOfLine = this.GetStartPositionOfLine(lineIndex);
    TextPointer endPositionOfLine = this.GetEndPositionOfLine(lineIndex);
    if ((startPositionOfLine != null) && (endPositionOfLine != null))
    {
        return TextRangeBase.GetTextInternal(startPositionOfLine, endPositionOfLine);
    }
    return this.Text;
}

The Code

Rendering

This version of the CodeBox only renders the text that would be currently visible, for greater efficiency. The textbox provides a few methods that can be used to determine the text that is visible and its position. There are two main wrinkles though. First, the methods used to get this information do not function in the designer. Secondly, the methods cannot be depended on. When they fail to give values, which usually occurs when the text is changing, the control will have to repeat the last render and then try the render again later.

In order to render the text, we need to know two things: the text to use, and how much it is scrolled up or down. The visible text is created as follows:

private  string VisibleText
{
    get
    {
        if (this.Text == "") { return ""; }
        string visibleText = "";
        try
        {
            int textLength = Text.Length;
            int firstLine = GetFirstVisibleLineIndex();
            int lastLine = GetLastVisibleLineIndex();

            int lineCount = this.LineCount;
            int firstChar = 
               (firstLine == 0) ? 0 : GetCharacterIndexFromLineIndex(firstLine);

            int lastChar = GetCharacterIndexFromLineIndex(lastLine) + 
                           GetLineLength(lastLine) - 1;
            int length = lastChar - firstChar + 1;
            int maxlenght = textLength - firstChar;
            string text =  Text.Substring(firstChar, Math.Min(maxlenght, length));
            if (text != null)
            {
                visibleText = text;
            }
        }
        catch
        {
            Debug.WriteLine("GetVisibleText failure");
        }
    return    visibleText;
    }
}

We get the first character of the first visible line, the last character of the last visible line, and the text must be everything that lies between them. It should be noted that GetFirstVisibleLineIndex and GetLastVisibleLineIndex are on the generous side. Lines can be declared visible even if some of their allotted space is visible, though they are undetectable to the human eye.

The other thing that we need to know in order to render the text is the location we will use to render from.

private Point GetRenderPoint(int firstChar)
{
    try
    {
        Rect cRect = GetRectFromCharacterIndex(firstChar);
        Point  renderPoint = new Point(cRect.Left, cRect.Top);
        if (!Double.IsInfinity(cRect.Top))
        {
            renderinfo.RenderPoint = renderPoint;
        }
        else
        {
             this.renderTimer.IsEnabled = true;
        }
        return renderinfo.RenderPoint;
    }
    catch
    {
        this.renderTimer.IsEnabled = true;
        return renderinfo.RenderPoint;
    }
}

This code seems bloated. The calculation requires only two lines.

Rect cRect = GetRectFromCharacterIndex(firstChar);
Point  renderPoint = new Point(cRect.Left, cRect.Top);

The origin for rendering purposes is the top left corner of the first visible character. The rest of the code exists to handle the fact that the textbox is not ready to give that information all the time. I suspect that things are being handled asynchronously under the hood, but the code we see in Reflector is pretty hairy.

Finally, we come to the main method of the CodeBox control: OnRenderRuntime.

protected void OnRenderRuntime(DrawingContext drawingContext)
{
   drawingContext.PushClip(new RectangleGeometry(new Rect(0, 0, this.ActualWidth, 
                           this.ActualHeight)));//restrict drawing to textbox
   drawingContext.DrawRectangle(CodeBoxBackground, null, new Rect(0, 0, 
                  this.ActualWidth, this.ActualHeight));//Draw Background
   if (this.Text == "") return;

       int firstLine = GetFirstVisibleLineIndex();// GetFirstLine();
       int firstChar = (firstLine == 0) ? 0 : 
           GetCharacterIndexFromLineIndex(firstLine);// GetFirstChar();
       string visibleText = VisibleText;
       if (visibleText == null) return;

       Double leftMargin = 4.0 + this.BorderThickness.Left;
       Double topMargin = 2.0 + this.BorderThickness.Top;

       formattedText = new FormattedText(
              this.VisibleText,
               CultureInfo.GetCultureInfo("en-us"),
               FlowDirection.LeftToRight,
               new Typeface(this.FontFamily.Source),
               this.FontSize,
               BaseForeground);  //Text that matches the textbox's
       formattedText.Trimming = TextTrimming.None;

       ApplyTextWrapping(formattedText);

               Pair visiblePair = new Pair(firstChar, visibleText.Length);
               Point renderPoint =   GetRenderPoint(firstChar);
               
                //Generates the prepared decorations for the BaseDecorations
               Dictionary<EDecorationType, Dictionary<Decoration, 
                          List<Geometry>>> basePreparedDecorations 
                   = GeneratePreparedDecorations(visiblePair, 
                     DecorationScheme.BaseDecorations);
               //Displays the prepared decorations for the BaseDecorations
               DisplayPreparedDecorations(drawingContext, 
                              basePreparedDecorations, renderPoint);

               //Generates the prepared decorations for the Decorations
               Dictionary<EDecorationType, Dictionary<Decoration, 
                          List<Geometry>>> preparedDecorations 
                   = GeneratePreparedDecorations(visiblePair, mDecorations);
               //Displays the prepared decorations for the Decorations
               DisplayPreparedDecorations(drawingContext, 
                      preparedDecorations, renderPoint);

               //Colors According to Scheme
               ColorText(firstChar, DecorationScheme.BaseDecorations);
               ColorText(firstChar, mDecorations);//Colors According to Decorations
               drawingContext.DrawText(formattedText, renderPoint);

               if (this.LineNumberMarginWidth > 0)
               //Are line numbers being used
               {
                   //Even if we gey this far it is still 
                   //possible for the line numbers to fail
                   if (this.GetLastVisibleLineIndex() != -1)
                   {
                       FormattedText lineNumbers = GenerateLineNumbers();
                       drawingContext.DrawText(lineNumbers, new Point(3, 
                                               renderPoint.Y));
                       renderinfo.LineNumbers = lineNumbers;
                   }
                   else
                   {
                       drawingContext.DrawText(renderinfo.LineNumbers, 
                                               new Point(3, renderPoint.Y));
                   }
               }

           //Cache information for possible renderer
            renderinfo.BoxText = formattedText;
            renderinfo.BasePreparedDecorations = basePreparedDecorations;
            renderinfo.PreparedDecorations = preparedDecorations;
       }

I've looked at this code so long that it seems self explanatory, but here are the bullet points of what it does:

  • Render the background
  • Determine the visible text
  • Create the FormattedText used to display the text and create the decorations
  • Get the render point
  • Create and then display the decorations for the DecorationSchemes
  • Create and then display the decorations for the Decorations
  • Perform and display the text coloring for the DecorationScheme and then the Decorations
  • Create the line numbers
  • Cache the RenderInfo data so that this render can be quickly repeated

Line Numbers

Adding line numbers first requires that we alter the ControlTemplate for the CodeBox and then generate the line numbers.

LineNumber XAML

In order to add a margin to the text box, the control part of the ControlTemplate was changed from:

<Border BorderThickness="{TemplateBinding Border.BorderThickness}" 
   BorderBrush="{TemplateBinding Border.BorderBrush}"
   Background="{TemplateBinding Panel.Background}" 
   Name="Bd" SnapsToDevicePixels="True">
     <ScrollViewer Name="PART_ContentHost" 
        SnapsToDevicePixels="{TemplateBinding UIElement.SnapsToDevicePixels}" />

to:

<Border BorderThickness="{TemplateBinding Border.BorderThickness}" 
    BorderBrush="{TemplateBinding Border.BorderBrush}"
    Background="{TemplateBinding Panel.Background}" 
    Name="Bd" SnapsToDevicePixels="True" > 
        <Grid Background="Transparent"  >
                <Grid.ColumnDefinitions>
                    <ColumnDefinition 
                       Width="{Binding Path =  LineNumberMarginWidth, 
                              RelativeSource={RelativeSource Templatedparent}, 
                              Mode=OneWay}" />
                    <ColumnDefinition  Width ="*"/>  
                </Grid.ColumnDefinitions>
           <ScrollViewer Name="PART_ContentHost" 
               SnapsToDevicePixels="{TemplateBinding UIElement.SnapsToDevicePixels}" 
               Grid.Column="1" />
            </Grid></Border >

The most important thing to note is that the Mode for the width of the line number column is OneWay. Leaving it at the default (TwoWay) gives poor performance. We can also run into some trouble with our control templates not being recognized. Creating and then modifying a user control is an odd but effective workaround.

Generating the Line Numbers

There are two distinct line numbering scenarios: wrapping and no wrapping. In both cases, a string is created with \n characters to separate the lines. This is then used to create a formatted text object representing the line numbers. Without wrapping, creating this string is rather simple.

int firstLine = GetFirstVisibleLineIndex();  
int lastLine = GetLastVisibleLineIndex(); 
StringBuilder sb = new StringBuilder();
for (int i = firstLine; i <= lastLine; i++)
{
    sb.Append((i + StartingLineNumber) + "\n");
}
string lineNumberString = sb.ToString();

The case with wrapping is more involved. It involves three methods:

  • MinLineStartCharcterPositions -- The character positions that start lines as determined only by the characters
  • VisibleLineStartCharcterPositions -- The character positions beginning the lines in the textbox
  • lineNumberString -- The string that comes from merging the MinLineStartCharcterPositions and the VisibleLineStartCharcterPosition

The textbox is wider than any of the lines of text. When the box is thinner than the width of the longest line, then wrapping occurs, and there are additional elements in VisibleLineStartCharcterPositions that are not in MinLineStartCharcterPositions. All of the elements in MinLineStartCharcterPositions should appear in VisibleLineStartCharcterPositions plus some additional ones. We should then be able to go down the VisibleLineStartCharcterPositions, checking for the matches in MinLineStartCharcterPositions and thus get the line numbers. If there is interest, please comment and I will add a more detailed explanation.

The LineNumbers method uses the merge algorithm, which is reasonably efficient for working with sorted lists. The method is about 70 lines long, so I will resist putting it here. It is amply documented though.

Using the Code

For all of the 900+ lines of code in the CodeBox class, it is after all just a TextBox. Once the appropriate namespaces and references are added, it can just be used. The two caveats are that you should use the CodeBoxBackground for the Background, and the BaseForeground for the ForeGround. If you do not want line numbers, set the LineNumberMarginWidth to 0. The DecorationSchemes class contains two incomplete decoration schemes, one for SQL Server 2008 and one for C# 3.0. They can serve as good examples of how to define decorations.

Sample Application - ColoredWordPad

For a sample application, I created a very simple word processor that allows us to place decorations on the text. Because I wanted to make it useful, it has a nontrivial feature. The text displayed can be exported to an image file, without the line for the insertion point. Again, if anyone is interested, let me know and I will add a more detailed explanation.

Updates

10/9/2009

  • Two additional types of Decorations have been added. The DoubleRegexDecoration uses a pair of regular expressions to determine the selected area which is very useful in situations where the first regex would define the container and the second defines the specific items in that container. The RegexMatchDecoration uses a regular expression and one of its matching group names to specify the positions.
  • Three additional decoration schemes have been added, for XAML, XML and DBML. The XAML and XML ones extensively use the DoubleRegexDecoration and RegexMatchDecoration. The XML one should be just right, however the XAML one still needs to be able to handle nested markup expressions. There is also a DecorationSchemes type converter.
  • If you want to see the CodeBox in action, you can check out my Control Template Viewer from this ClickOnce installation site.

Conclusion

Version 1 of the CodeBox should be thought of as a proof of concept, while this version should probably be considered an alpha release for a control. Hopefully, this one will prove good enough to serve in your applications.

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