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 (
BaseDecoration
s) 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;
str = str.Replace("\r\n", "\n"); char[] chars = str.ToCharArray();
int breakCount = 0;
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; 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))); drawingContext.DrawRectangle(CodeBoxBackground, null, new Rect(0, 0,
this.ActualWidth, this.ActualHeight)); if (this.Text == "") return;
int firstLine = GetFirstVisibleLineIndex(); int firstChar = (firstLine == 0) ? 0 :
GetCharacterIndexFromLineIndex(firstLine); 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); formattedText.Trimming = TextTrimming.None;
ApplyTextWrapping(formattedText);
Pair visiblePair = new Pair(firstChar, visibleText.Length);
Point renderPoint = GetRenderPoint(firstChar);
Dictionary<EDecorationType, Dictionary<Decoration,
List<Geometry>>> basePreparedDecorations
= GeneratePreparedDecorations(visiblePair,
DecorationScheme.BaseDecorations);
DisplayPreparedDecorations(drawingContext,
basePreparedDecorations, renderPoint);
Dictionary<EDecorationType, Dictionary<Decoration,
List<Geometry>>> preparedDecorations
= GeneratePreparedDecorations(visiblePair, mDecorations);
DisplayPreparedDecorations(drawingContext,
preparedDecorations, renderPoint);
ColorText(firstChar, DecorationScheme.BaseDecorations);
ColorText(firstChar, mDecorations); drawingContext.DrawText(formattedText, renderPoint);
if (this.LineNumberMarginWidth > 0)
{
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));
}
}
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
DecorationScheme
s
- Create and then display the decorations for the
Decoration
s
- Perform and display the text coloring for the
DecorationScheme
and then the Decoration
s
- 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.