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

CodeBox

0.00/5 (No votes)
10 Mar 2009 1  
A fast WPF textbox control with support for text coloring, highlighting, underlines, and strikethroughs.

Codebox Image

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 that matches the textbox's

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; // space for scrollbar
formattedText.MaxTextHeight = this.ActualHeight + this.VerticalOffset;
//Adjust for scrolling

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;
          // ordered values of X that are present for both Y values
          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.

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