How to highlight wrapped text in a control using the graphics?

Solution 1:

There is no clear specification of which controls to target, so I'm testing 3 different:
TextBox, RichTextbox and ListBox.

TextBox and RichTextbox have the same behavior and share the same tools, so there's no need to define two different methods to achieve the same result.
Of course RichTextbox offers many more options, including RTF.

Also, I'm testing both Graphics.DrawString() and TextRenderer.DrawText().

This is the result of this test, so it's more clear what the code does.

enter image description here

Warning:
For this example I'm using Control.CreateGraphics(), because TextBox and RichTextBox controls don't provide a Paint() event. For a real world application, you should create a Custom Control derived from TextBox or RichTextBox, override WndPrc and handle WM_PAINT.

1) Highlight all t in a multiline TextBox control.

TextRenderer->DrawText():

//Define some useful flags for TextRenderer
TextFormatFlags flags = TextFormatFlags.Left | TextFormatFlags.Top | 
                        TextFormatFlags.NoPadding | TextFormatFlags.WordBreak | 
                        TextFormatFlags.TextBoxControl;
//The char to look for
char TheChar = 't';

//Find all 't' chars indexes in the text
List<int> TheIndexList = textBox1.Text.Select((chr, idx) => chr == TheChar ? idx : -1)
                                      .Where(idx => idx != -1).ToList();

//Or with Regex - same thing, pick the one you prefer
List<int> TheIndexList = Regex.Matches(textBox1.Text, TheChar.ToString())
                              .Cast<Match>()
                              .Select(chr => chr.Index).ToList();

//Using .GetPositionFromCharIndex(), define the Point [p] where the highlighted text is drawn
if (TheIndexList.Count > 0)
{
    foreach (int Position in TheIndexList)
    {
        Point p = textBox1.GetPositionFromCharIndex(Position);
        using (Graphics g = textBox1.CreateGraphics())
               TextRenderer.DrawText(g, TheChar.ToString(), textBox1.Font, p,
                                     textBox1.ForeColor, Color.LightGreen, flags);
    }
}

The same operation using Graphics.FillRectangle() and Graphics.DrawString():

if (TheIndexList.Count > 0)
{
    using (Graphics g = textBox1.CreateGraphics())
    {
        foreach (int Position in TheIndexList)
        {
            PointF pF = textBox1.GetPositionFromCharIndex(Position);
            SizeF sF = g.MeasureString(TheChar.ToString(), textBox1.Font, 0,
                                       StringFormat.GenericTypographic);

            g.FillRectangle(Brushes.LightGreen, new RectangleF(pF, sF));
            using (SolidBrush brush = new SolidBrush(textBox1.ForeColor))
            {
                g.TextRenderingHint = TextRenderingHint.ClearTypeGridFit;
                g.DrawString(TheChar.ToString(), textBox1.Font, brush, pF, StringFormat.GenericTypographic);
            }
        }
    }
}

There is no notable difference in behavior: TextRenderer.DrawText() and Graphics.DrawString() do the exact same thing here.
Setting Application.SetCompatibleTextRenderingDefault() to true or false does not seem to have any affect (in the current context, at least).

2) Highlight some string patterns ("Words") in a TextBox control and a multiline RichTextbox control.

Using TextRenderer only, since there's no difference in behavior.

I'm simply letting IndexOf() find the the first occurrence of the strings, but the same search pattern used before can take it's place. Regex works better.

string[] TheStrings = {"for", "s"};
foreach (string pattern in TheStrings)
{
    Point p = TextBox2.GetPositionFromCharIndex(TextBox2.Text.IndexOf(pattern));
    using (var g = TextBox2.CreateGraphics()) { 
        TextRenderer.DrawText(g, pattern, TextBox2.Font, p, 
                              TextBox2.ForeColor, Color.LightSkyBlue, flags);
    }
}

TheStrings = new string []{"m", "more"};
foreach (string pattern in TheStrings)
{
    Point p = richTextBox1.GetPositionFromCharIndex(richTextBox1.Text.IndexOf(pattern));
    using (Graphics g = richTextBox1.CreateGraphics())
        TextRenderer.DrawText(g, pattern, richTextBox1.Font, p,
                              richTextBox1.ForeColor, Color.LightSteelBlue, flags);
}

3) Highlight all s in all the ListItems of a ListBox control (of course it can be any other string :)

The ListBox.DrawMode is set to Normal and changed "on the fly" to OwnerDrawVariable to evaluate whether TextRenderer and Graphics behave differently here.

There is a small difference: a different offset, relative to the left margin of the ListBox, compared to the standard implementation. TextRenderer, with TextFormatFlags.NoPadding renders 2 pixels to the left (the opposite without the flag). Graphics renders 1 pixel to the right.
Of course if OwnerDrawVariable is set in design mode, this will not be noticed.

string HighLightString = "s";
int GraphicsPaddingOffset = 1;
int TextRendererPaddingOffset = 2;

private void button1_Click(object sender, EventArgs e)
{
    listBox1.DrawMode = DrawMode.OwnerDrawVariable;
}

How the following code works:

  1. Get all the positions in the ListItem text where the pattern (string HighLightString) appears.
  2. Define an array of CharacterRange structures with the position and length of the pattern.
  3. Fill a StringFormat with all the CharacterRange structs using .SetMeasurableCharacterRanges()
  4. Define an array of Regions using Graphics.MeasureCharacterRanges() passing the initialized StringFormat.
  5. Define an array of Rectangles sized using Region.GetBounds()
  6. Fill all the Rectangles with the highlight color using Graphics.FillRectangles()
  7. Draw the ListItem text.

TextRenderer.DrawText() implementation:

private void listBox1_DrawItem(object sender, DrawItemEventArgs e)
{
    e.DrawBackground();

    TextFormatFlags flags = TextFormatFlags.Left | TextFormatFlags.Top | TextFormatFlags.NoPadding |
                            TextFormatFlags.WordBreak | TextFormatFlags.TextBoxControl;
    Rectangle bounds = new Rectangle(e.Bounds.X + TextRendererPaddingOffset, 
                                     e.Bounds.Y, e.Bounds.Width, e.Bounds.Height);

    string ItemString = listBox1.GetItemText(listBox1.Items[e.Index]);
    List<int> TheIndexList = Regex.Matches(ItemString, HighLightString)
                                  .Cast<Match>()
                                  .Select(s => s.Index).ToList();

    if (TheIndexList.Count > 0)
    {
        CharacterRange[] CharRanges = new CharacterRange[TheIndexList.Count];
        for (int CharX = 0; CharX < TheIndexList.Count; CharX++)
            CharRanges[CharX] = new CharacterRange(TheIndexList[CharX], HighLightString.Length);

        StringFormat format = new StringFormat(StringFormat.GenericDefault);
        format.SetMeasurableCharacterRanges(CharRanges);

        Region[] regions = e.Graphics.MeasureCharacterRanges(ItemString, e.Font, e.Bounds, format);

        RectangleF[] rectsF = new RectangleF[regions.Length];
        for (int RFx = 0; RFx < regions.Length; RFx++)
            rectsF[RFx] = regions[RFx].GetBounds(e.Graphics);

        e.Graphics.FillRectangles(Brushes.LightGreen, rectsF);
    }
    TextRenderer.DrawText(e.Graphics, ItemString, e.Font, bounds, e.ForeColor, flags);
}

`Graphics.DrawString()` implementation
private void listBox1_DrawItem(object sender, DrawItemEventArgs e)
{
    e.DrawBackground();
    Rectangle bounds = new Rectangle(e.Bounds.X - GraphicsPaddingOffset,
                                     e.Bounds.Y, e.Bounds.Width, e.Bounds.Height);

    string ItemString = listBox1.GetItemText(listBox1.Items[e.Index]);
    List<int> TheIndexList = Regex.Matches(ItemString, HighLightString)
                                  .Cast<Match>()
                                  .Select(s => s.Index).ToList();

    StringFormat format = new StringFormat(StringFormat.GenericDefault);
    if (TheIndexList.Count > 0)
    {
        CharacterRange[] CharRanges = new CharacterRange[TheIndexList.Count];
        for (int CharX = 0; CharX < TheIndexList.Count; CharX++)
            CharRanges[CharX] = new CharacterRange(TheIndexList[CharX], HighLightString.Length);

        format.SetMeasurableCharacterRanges(CharRanges);
        Region[] regions = e.Graphics.MeasureCharacterRanges(ItemString, e.Font, e.Bounds, format);

        RectangleF[] rectsF = new RectangleF[regions.Length];
        for (int RFx = 0; RFx < regions.Length; RFx++)
            rectsF[RFx] = regions[RFx].GetBounds(e.Graphics);

        e.Graphics.FillRectangles(Brushes.LightGreen, rectsF);
    }
    using (SolidBrush brush = new SolidBrush(e.ForeColor))
        e.Graphics.DrawString(ItemString, e.Font, brush, bounds, format);
}

Note:
Depending on the ListBox.DrawMode, it may become necessary to subscribe the ListBox.MeasureItem() event or set the .ItemHeight property to the corrent value.

private void listBox1_MeasureItem(object sender, MeasureItemEventArgs e)
{
      e.ItemHeight = listBox1.Font.Height;
}