Introduction
This article presents an enhanced text box control that is designed to facilitate text coloring, highlighting, underlining, and striking. As it is derived from the TextBox
control, rather than the RichTextBox
, it is quite speedy.
Background
I was in the process of upgrading a Regular Expression generating tool of mine. For the nth time, I was considering moving it from WinForms to WPF. My problem was the painful slowness of the RichTextBox
control. I had tried numerous times to somehow get performance that compared to the WinForms RichTextBox
, but failed. This time, I tried something different. For the heck of it, I overrode the OnRender
method, and wrote a little text to the TextBox
. I was surprised to find that both the textbox’s text and my additional text were visible, as I expected only my overridden text to appear. It took a few moments to progress from Oh that’s odd to Wow, my problem is solved, as I could now do the following.
- Recreate the same text as was in the textbox, but colorized and otherwise decorated
- Since both sets of text are visible, I can make sure that they line up exactly
- The original text's brush can be set to something transparent so it will not cover up the decorated text
- Selecting and editing will be handled by the underling text box functionality
I figured that this couldn’t possibly be slower than the RichTextBox
, so I gave it a try. It exceeded my expectations, so here it is.
Using the Code
This control can be used like a regular textbox with the following caveats. The background and foreground brushes should both be set to something transparent. This is done in the constructor so all one needs to do is not set them. In order to set the default text color, use the BaseForeground
property. In order to set the background color, just wrap it in a border. Hopefully, I will be able to remove this nonstandard behavior soon. Text coloring rules are set through the Decorations
property.
The Code
There were several issues that needed to be taken care of. As the original purpose of this control was to highlight text affected by Regular Expressions in various ways, I needed an extensible method for making text selections. I expected that in time I could have quite a number of them. I also needed to take care of scrolling, text coloring, background coloring, underlining, and strikethroughs.
Text Selection and Decorations
The classes used to select the text for decoration are all derived from the abstract base class Decoration
.
public abstract class Decoration:DependencyObject
{
public static DependencyProperty DecorationTypeProperty
= DependencyProperty.Register("DecorationType",
typeof(EDecorationType), typeof(Decoration),
new PropertyMetadata(EDecorationType.TextColor));
public EDecorationType DecorationType
{
get { return (EDecorationType)GetValue(DecorationTypeProperty); }
set { SetValue(DecorationTypeProperty, value); }
}
public static DependencyProperty BrushProperty
= DependencyProperty.Register("Brush",
typeof(Brush), typeof(Decoration),
new PropertyMetadata(null));
public Brush Brush
{
get { return (Brush)GetValue(BrushProperty); }
set { SetValue(BrushProperty, value); }
}
public abstract List<pair> Ranges(string Text);
}
The Decoration
type refers to members of the enumeration EDecorationType
which lists the various styles available.
public enum EDecorationType
{
TextColor,
Hilight,
Underline,
Strikethrough
}
Ranges
is the important method. It returns a list of Pair
objects, which represent the starting point and length of the character selection.
public class Pair
{
public int Start { get; set; }
public int Length { get; set; }
public Pair(){}
public Pair(int start, int length)
{
Start = start;
Length = length;
}
}
Please note that the list is dependent on the text involved. I have included eight Decoration
classes.
ExplicitDecoration
: Selects based on a starting character position and the number of characters
MultiExplicitDecoration
: Selects based on a list of starting character positions and number of characters
StringDecoration
: Selects based on String.IndexOf
for a given string
MultiStringDecoration
: Selects based on String.IndexOf
for a list of given strings
RegexDecoration
: Selects based on matching a Regular Expression
MultiRexexDecoration
: Selects based on matching any of a list of Regular Expressions
RegexWordDecoration
: Selects based on a string enclosed by word boundaries
MultiRegexWordDecoration
: selects based on a list of strings enclosed by word boundaries
If one uses this for syntax coloring the MultiRegexWordDecoration
is probably the most useful.
public class MultiRegexWordDecoration:Decoration
{
private List<string> mWords = new List<string>();
public List<string> Words
{
get { return mWords; }
set { mWords = value; }
}
public bool IsCaseSensitive { get; set; }
public override List<pair> Ranges(string Text)
{
List<pair> pairs = new List<pair>();
foreach (string word in mWords)
{
string rstring = @"(?i:\b" + word + @"\b)";
if (IsCaseSensitive)
{
rstring = @"\b" + word + @"\b)";
}
Regex rx = new Regex(rstring);
MatchCollection mc = rx.Matches(Text);
foreach (Match m in mc)
{
pairs.Add(new Pair(m.Index, m.Length));
}
}
return pairs;
}
}
Please note that "words" such as @@fetch_status
will not be found using this Decoration. In this case, the word boundary is before the f rather than the first @. It is also worth noting that even with well over one hundred words, this Decoration's performance is quite good.
Text Creation
Text creation and all that follows depends on the FormattedText
class. The following will give us the same text as originally in the TextBox
.
FormattedText formattedText = new FormattedText(
this.Text,
CultureInfo.GetCultureInfo("en-us"),
FlowDirection.LeftToRight,
new Typeface(this.FontFamily.Source),
this.FontSize,
BaseForeground);
Text Coloring
Once we have the text, coloring it is pretty easy. We use the SetForegroundBrush
method using the information from the Decoration
objects.
foreach (Decoration dec in mDecorations)
{
if (dec.DecorationType == EDecorationType.TextColor)
{
List<pair> ranges = dec.Ranges(this.Text);
foreach (Pair p in ranges)
{
formattedText.SetForegroundBrush(dec.Brush, p.Start, p.Length);
}
}
}
This behaves as one hopes it would. Later, SetForegroundBrush
calls effectively overrule earlier ones. In the SQL text example, sys.Comments is treated as a single word and set to a green brush. Later . is set to a gray brush.
We have green text with gray periods in between. Even though it would probably boost performance, cleaning up text color changes is not necessary.
Scrolling
Handling scrolling turned out to be a little tricky. The first and highest hurdle is that the TextBox
does not expose an event to indicate scrolling. This is distinct from the TextChanged
event which is used to keep the text synchronized. The ControlTemplate
for the textbox does have a scroll viewer in it though.
<ControlTemplate TargetType="c:CodeBox"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:s="clr-namespace:System;assembly=mscorlib"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml">
<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}" />
</Border >
<ControlTemplate.Triggers>...
</ControlTemplate.Triggers>
</ControlTemplate>
After the visual tree is created, we can attach a handler to the ScrollChanged
event of the ScrollViewer
in the visual tree.
private void EnsureScrolling()
{
if (!mScrollingEventEnabled)
{
DependencyObject dp = VisualTreeHelper.GetChild(this, 0);
ScrollViewer sv = VisualTreeHelper.GetChild(dp, 0) as ScrollViewer;
sv.ScrollChanged += new ScrollChangedEventHandler(ScrollChanged);
mScrollingEventEnabled = true;
}
}
private void ScrollChanged(object sender, ScrollChangedEventArgs e)
{
this.InvalidateVisual();
}
EnsureScrolling
is called in the beginning of the Render
method to ensure that the control is ready for scrolling.
Several other things need to be done in order for the rendered text to match the TextBox
text while scrolling. The width and height of the formatted text need to be adjusted.
double leftMargin =4.0 + this.BorderThickness.Left ;
double topMargin = 2.0 + this.BorderThickness.Top;
formattedText.MaxTextWidth = this.ViewportWidth;
formattedText.MaxTextHeight = this.ActualHeight + this.VerticalOffset;
The ViewportWidth
needs to be used for the width as it takes into account the existence or nonexistence of the scrollbar. The numbers for the left and top margins were found via trial and error. The text is drawn on the control as follows:
drawingContext.DrawText(formattedText,
new Point(leftMargin, topMargin - this.VerticalOffset));
The only other issue that needs to be taken care of is clipping the rendered text so that it is only visible within the textbox.
drawingContext.PushClip(new RectangleGeometry(new Rect(0, 0,
this.ActualWidth, this.ActualHeight)));
This is done prior to calling any of the drawing methods of the DrawingContext
.
Background Coloring
The FormattedText
class also provides a BuildHighlightGeometry
method to provide the necessary geometry for highlighting the text background. It should be noted that it works with both single and multiline highlights. They are added as follows:
foreach (Decoration dec in mDecorations)
{
if (dec.DecorationType == EDecorationType.Hilight)
{
List<pair> ranges = dec.Ranges(this.Text);
foreach (Pair p in ranges)
{
Geometry geom =
formattedText.BuildHighlightGeometry(
new Point(leftMargin, topMargin - this.VerticalOffset),
p.Start, p.Length);
if (geom != null)
{
drawingContext.DrawGeometry(dec.Brush, null, geom);
}
}
}
}
Underlining and Strikethroughs
In the case of text highlighting, the geometry that was drawn was exactly the same as the one produced from the FormattedText
object. We could transform the geometry before calling the DrawGeometry
method. An underline could be thought of as the bottom part of the rectangles that when aggregated form the geometry. In the same way, strikethroughs could be thought of as the middles. The case where the highlight is not confined to a single line turns out to be a bit involved. StackedRectangleGeometryHelper
and PointCollectionHelper
exist for this task. The plan is to get the set of points representing the geometry, change them, and then generate a new geometry from them.
public List<geometry> BottomEdgeRectangleGeometries()
{
List<geometry> geoms = new List<geometry>();
PathGeometry pg = (PathGeometry)mOrginalGeometry;
foreach (PathFigure fg in pg.Figures)
{
PolyLineSegment pls = (PolyLineSegment)fg.Segments[0];
PointCollectionHelper pch =
new PointCollectionHelper(pls.Points, fg.StartPoint);
List<double> distinctY = pch.DistinctY;
for (int i = 0; i < distinctY.Count - 1; i++)
{
double bottom = distinctY[i + 1] - 3;
double top = bottom + 2;
List<double> rlMatches = pch.XAtY(distinctY[i], distinctY[i + 1]);
double left = rlMatches[0];
double right = rlMatches[rlMatches.Count - 1];
PathGeometry rpg = CreateGeometry(top, bottom, left, right);
geoms.Add(rpg);
}
}
return geoms;
}
There are three things to be noted. In the event that there are some line breaks in the text that we are getting the geometry from, there can be more than one element in the Figures
collection. Second, the Pathfigure
is composed of a single PolyLineSegment
. The HilightGeometry
is created by combining of the paths for the rectangles. Simplification does not occur.
The extra point on the left middle is not removed. This is very important as it ensures the following procedure will work: Group the points into rows based on their Y values. Then, get the intersection of adjacent rows. The smallest and the largest values will be the left and right sides of the rectangles.
Conclusion
Hopefully, this will fill a small gap that has existed in the framework. I had not originally intended to publish this, but then I remembered that the original version of the application I made it for used code adapted from C# - Formatting Text in a RichTextBox by Parsing the Rich Text, so I decided to return the favor.
Revisions
- 3/10/09 - Added adjustment for border thickness; set default foreground and background to
Colors.Transparent
.