65.9K
CodeProject 正在变化。 阅读更多。
Home

Windows Forms 代码框

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.95/5 (28投票s)

2009 年 10 月 30 日

CPOL

11分钟阅读

viewsIcon

87505

downloadIcon

2439

一个支持灵活高亮和背景着色的 Windows Forms RichTextBox。

引言

本文介绍了一个 WinForms 文本编辑控件,它支持灵活的高亮和单词着色装饰系统。我的目标是足够简单地展示这个控件以及为了让它以合理的速度运行而进行的优化,以便 C# 新手能够理解它。

背景

这是我发布的第三个 CodeBox。前两个是为 WPF 构建的。我想 CodeBox 及其经过根本性重新设计的后继版本 CodeBox 2 可能会有用,尽管这个 WinForms 实现非常不同。它们都共享相同的基本装饰概念。

Using the Code

代码应该不难使用,因为该控件继承自 RichTextBox。不添加 Decoration,它几乎与 RichTextBox 无法区分。装饰分为两大类,分别对应 DecorationSchemeDecoration 类。DecorationScheme 本质上只是一个方便分组的 Decoration 项的集合。例如

codeBox.DecorationScheme = WinFormsCodeBox.Decorations.DecorationSchemes.CSharp3;

将提供类似于 Visual Studio 中 C# 代码着色的效果,而

codeBox.DecorationScheme = WinFormsCodeBox.Decorations.DecorationSchemes.Xml;

将提供类似于 Visual Studio 中 XML 外观的着色效果。DecorationScheme 用于设置文本的基本外观。之后,我们可以设置其他装饰。

LineDecoration ld = new LineDecoration()
{
  DecorationType = EDecorationType.Hilight 
  ,Color = Color.Yellow
  ,Line =2
};
codeBox.Decorations.Add(ld);

上面的代码会将 CodeBox 的第二行高亮显示为黄色。请注意,添加装饰不会自动更新显示。更新发生在 Text 更改时,或者

codeBox.ApplyDecorations(); 

被调用时。目前,有许多预制的装饰

  • StringDecoration:基于单个字符串索引位置的装饰
  • MultiStringDecoration:基于字符串列表索引位置的装饰
  • RegexDecoration:基于单个正则表达式字符串的装饰
  • MultiStringDecoration:基于正则表达式字符串列表的装饰
  • ExplicitDecoration:显式指定为起始位置和长度的装饰 - 简单但与选择配合使用时很有用
  • MultiExplicitDecoration:显式指定为起始位置和长度列表的装饰
  • MultiRegexWordDecoration:基于被单词边界夹在中间的字符串列表的装饰
  • DoubleQuotedDecoration:双引号内文本的装饰
  • LineDecoration:指定文本行的装饰
  • MultiLineDecoration:指定文本行列表的装饰
  • DoubleRegexDecoration:基于一对正则表达式字符串的装饰,其中第二个表达式匹配第一个表达式的结果
  • RegexMatchDecoration:基于正则表达式的匹配和组的装饰

让我们看几个例子

ExplicitDecoration ed = new ExplicitDecoration()
{
     Start = this.CodeBox.SelectionStart,
     Length = this.CodeBox.SelectionLength,
     DecorationType = EDecorationType.TextColor ,
     Color = Color.Green
};
this.CodeBox.Decorations.Add(ed);

假设我们有一个名为 CodeBoxWinFormsCodeBox,这将使选中文本的颜色变为 Green

RegexDecoration singleLineComment = new RegexDecoration()
{
    DecorationType = EDecorationType.TextColor,
    Color = Color.Green,
    RegexString = "//.*"
};

此装饰会将单行注释着色为 Green (C# 风格)。

private static List<string> CSharpVariableReservations()
{
    return new List<string>() { "string", "int", "double", 
           "long", "void" , "true", 
           "false", "null"};
}

MultiRegexWordDecoration BlueClasses = new MultiRegexWordDecoration()
{
    Color = Color.Blue,
    Words = CSharpVariableReservations(),
    IsCaseSensitive = true
};

这两个结合起来会将 CSharpVariableReservations 中定义的单词着色为 blue。请注意,string 会是 blue,但 happystring 则不会被着色。

RegexMatchDecoration xmlAttributeValue = new RegexMatchDecoration()
{
    Color = Color.Blue,
    RegexString = @"\s(\w+|\w+:\w+|(\w|\.)+)\s*=\s*""(?<selected />.*?)"""
};

这将使 XML 标签的属性部分着色为 Red

C#、SQL Server、XAML、DBML 和 XML 有预制的装饰方案。诚然,它们可能还需要一些改进,但它们都工作得相当好。掌握这些就基本可以开始使用 WinFormsCodeBox 了。

工作原理

基本思路

WinFormsCodeBox 继承自 RichTextBox。要进行的装饰是创建并通过移动选区并设置 SelectionColorSelectionBackColor 属性来应用的。

装饰是根据 TextIndex 类定义的。(请注意,在以前的 CodeBox 文章中,TextIndex 被称为 Pair。)

namespace TextUtils
{
    /// <summary>
    /// A pair of integers referring to the starting position
    /// and length of a piece of text
    /// </summary>
    public class TextIndex : IComparable<textindex>
    {
        /// <summary>
        ///The integer position of the first character
        /// </summary>
        public int Start { get; set; }
        
        /// <summary>
        /// Number of characters in range
        /// </summary>
        public int Length { get; set; }
        
        ... Other stuff
      }
}

这些被组合到 TextIndexList

public class TextIndexList : List<textindex>
{ ... lots of methods}

这些 TextIndexList 是由各种装饰创建的。所有装饰类都继承自 abstractDecoration

public abstract class Decoration
{
    public EDecorationType DecorationType { get; set; }
    public Color Color{ get; set; }
    public abstract TextIndexList  Ranges(string text);
    
    ... other stuff
}

然后通过 ApplyDecoration 方法将这些装饰应用到 WinFormsCodeBox

private void ApplyDecoration(Decoration d, TextIndexList tl)
{
    switch (d.DecorationType)
    {
         case EDecorationType.TextColor:
             foreach (TextIndex t in tl)
             {
                 this.Select(t.Start, t.Length);
                 this.SelectionColor = d.Color;
             }
             break;
         case EDecorationType.Hilight :
             foreach (TextIndex t in tl)
             {
                 this.Select(t.Start, t.Length);
                 this.SelectionBackColor  = d.Color;
             }
             break;
    }
}

问题和第一次优化

到目前为止的代码只有两个小问题。它太慢了,无法使用,并且在输入时会出现滚动条上下抖动的问题。当这种情况发生时,我们需要决定如何处理。放弃是一个合理的选择,但在放弃之前,应该看看是否至少有一点希望。其中一个问题是,RichTextBoxOnTextChanged 事件不仅在字符更改时触发,还在每次格式更改时触发。修复这个问题很容易。

protected override void OnTextChanged(EventArgs e)
{  
     base.OnTextChanged(e);
     if (!mDecorationInProgress)
     {
         ApplyDecorations();
     }
}

mDecorationInProgressApplyDecorations 方法开始时设置为 true,然后在结束时重新设置为 false。这对速度有显着影响,但远不足以使控件可用。屏幕的问题在于,每当选区更改时,textbox 都会滚动以使选区可见。当需要向下滚动以显示某些内容,然后又需要滚动回原点时,就会出现跳跃。可能会偏离几行。如果 RichTextBox 具有垂直滚动位置属性,这很容易处理,但它没有。幸运的是,我过去曾被这个问题困扰过,所以我查找了我当时所做的。COM Interop 拯救了这一天。

[DllImport("user32.dll")]
private static extern int SendMessage(IntPtr hwndLock, Int32 wMsg, 
                                      Int32 wParam, ref Point pt

private Point ScrollPosition
{
    get
    {
        const int EM_GETSCROLLPOS = 0x0400 + 221;
        Point pt = new Point();
        SendMessage(this.Handle, EM_GETSCROLLPOS, 0, ref pt);
        return pt;
    }
    set
    {
        const int EM_SETSCROLLPOS = 0x0400 + 222;
        SendMessage(this.Handle, EM_SETSCROLLPOS, 0, ref value);
    }
}

这恰好是正确的函数。我相信我得感谢互联网上某个匿名的 guru。有了这样的属性,我们只需要调用

Point origScroll = ScrollPosition;

在处理选区之前,以及

ScrollPosition = origScroll;

在完成后。这完全解决了跳跃问题。碰巧的是,我的旧项目中还有另一段有用的代码,用于锁定屏幕更新的导入

[DllImport("user32", CharSet = CharSet.Ansi, SetLastError = true, ExactSpelling = true)]
private static extern int LockWindowUpdate(int hWnd);

在处理选区之前,我们调用

LockWindowUpdate(this.Handle.ToInt32());

之后,我们调用

LockWindowUpdate(0);

保存旧代码是好的。此时,该控件可以用于相对少量的文本,例如典型存储过程的定义。问题是该控件是否可以进一步优化。一些计时数据显示,应用装饰所花费的时间是从确定需要应用哪些装饰所需时间的 100 到 1000 倍。鉴于此,我制定了三种进一步优化的可能策略

  • 使用 Reflector 检查类,寻找有用的内部方法来设置属性
  • 深入底层,开始直接处理 RTF
  • 尝试更有效地应用装饰

我的第一个想法是使用 Reflector。在 WPF 中,这通常效果很好。这是 SelectionColor 属性的 set 部分的样子

set
{
    this.ForceHandleCreate();
    NativeMethods.CHARFORMATA charFormat = this.GetCharFormat(true);
    charFormat.dwMask = 0x40000000;
    charFormat.dwEffects = 0;
    charFormat.crTextColor = ColorTranslator.ToWin32(value);
    UnsafeNativeMethods.SendMessage(new HandleRef(this, base.Handle), 
                                    0x444, 1, charFormat);
}

这足以让我相信 Reflector 不会轻易给我带来好处。过去,我曾处理过 RTF,我不会将其作为最后的手段。这让我只剩下最后一个选择。RichTextBox 中的 RTF 是一种持久介质。我们不必在每次更新时都更新所有内容。我们可以只更新已更改且需要更新的区域。这样做需要更仔细地研究 TextIndexTextIndexList

TextIndexes 列表和第二次优化

为了只修改文本的更改部分,我们需要能够区分 TextindexList。我们可以从三种不同的方式来查看 TextindexList

  • TextIndexList 是一个 List
  • TextIndexList 可以看作是直线上的线段集合。
  • TextIndexList 可以看作是一个 BitArray

例如,考虑以下 TextIndexList

TextIndexList tl = new TextIndexList();
tl.Add(new TextIndex() { Start = 1, Length = 2 });
tl.Add(new TextIndex() { Start = 4, Length = 2 });

可以更简洁地创建:

TextIndexList tl = TextIndexList.Parse("1,2:4,2");

它包含与以下线段相同的信息

这相当于位数组 [false,true,true,false,true,true]。你可能有点怀疑,想知道我是否只是碰巧选了一个好例子。如果 TextIndex 重叠了会怎样?这是关键点。装饰的设计使得一次双重应用与一次应用相同。如果我们有一个黄色背景,它与另一个黄色背景重叠,它就相当于一个更大的黄色背景。顺序也没有意义,因此以下两个 TextIndexList 对象基本上是等效的

TextIndexList tl1 = TextIndexList.Parse("1,2:4,2"); 
TextIndexList tl2 = TextIndexList.Parse("4,2:1,2");

几何解释

理解如何确定需要更改的 TextIndex 的最小集合的最简单方法是几何上地看待线段的情况。所以,让我们考虑只有一个装饰的情况。有两个 TextIndexList 表示装饰将在文本中的何处应用。

应该注意的两件事是,TextIndexList 不同的范围清晰地定义了,并且它们的差异可以看作是一个 TextIndexList。这个新 TextIndexList 的边界可以看作是一个 TextIndex。在更新显示时,我们只需要关注“差异边界”区域内的文本格式的更新。通常,我们有不止一个装饰。SQL Server 的装饰方案包含 11 个。

当有更多装饰时,它们可以可视化为堆叠在一起。各个装饰的差异边界集形成一个 TextIndexList。从中,我可以得到一个整体的差异范围,即需要更新的区域。最后,通过将原始装饰投影到这个组合范围上来获得要应用的实际装饰。

BitArray 解释

要计算 TextIndexList,我们可以转向 TextIndexListBitArray 解释。从 TextIndexList 生成 BitArray 的例程很简单。

public BitArray ToBitArray(int size)
{
    BitArray bits = new BitArray(size);
    foreach (TextIndex t in this)
    {
        int maxVal = Math.Min(size, t.Start + t.Length);
        for (int i = t.Start; i < maxVal; i++)
        {
            bits[i] = true;
        }
    }
    return bits;
}

size 参数只是为了增加一点灵活性。只要它大于或等于 TextIndexList 的上限,转换就会完成。反向转换稍微难理解一些。

public static TextIndexList FromBitArray(BitArray bits)
{
    return FromBitArray(bits, new TextIndex (0, bits.Length));
}

public static TextIndexList FromBitArray(BitArray bits, TextIndex index)
{
    string bitString = BitArrayString(bits);
    TextIndexList tl = new TextIndexList();
    int currentStart = -1;
    int lastBit = Math.Min(index.Start + index.Length, bits.Length);
    for (int i = index.Start; i < lastBit; i++)
    {
        if (bits[i])
        {
            if (currentStart == -1)
            {
                currentStart = i;
            }
        }
        else
        {
            if (currentStart != -1)
            {
                tl.Add(TextIndex.FromStartEnd(currentStart, i  ));
                currentStart = -1;
            }
        }
    }
    if (currentStart != -1)
    {
        tl.Add(TextIndex.FromStartEnd(currentStart, index.End ));
    }
    return tl;
}

像这样的代码就是为什么我们有单元测试。有趣的是要注意这段代码

TextIndexList  tl = TextIndexList.FromBitArray(tl.ToBitArray());

它合并了重叠的 TextIndex 并对 TextIndexList tl 进行了排序。

为了找到两个 TextIndexList 的差异,我们可以取 BitArray 的对称差集。

public TextIndexList SymetricDifference(TextIndexList tl)
{
    int arraySize = Math.Max(this.Bounds.End, tl.Bounds.End);
    BitArray bArray = this.ToBitArray(arraySize);
    BitArray btlArray = tl.ToBitArray(arraySize);

    BitArray bResult = bArray.Xor(btlArray);
    return TextIndexList.FromBitArray(bResult);
}

如果这不明显,请花点时间看看线段图,应该会明白。我确信可以在没有 BitArray 的情况下完成,但 XOR 使其更加简洁。此外,我们可以使用 FromBitArray 方法创建投影。改变循环的起始和结束点可以用来将 TextIndexList 限制在指定的 TextIndex 中。

第三次优化 - Shift

第二次优化似乎应该会大大改善情况,但实际的改进相当有限。问题在于文本更改的性质。文本框文本最常见的更改方式是通过键入。在正常情况下(没有删除、退格键或之前的选择),每次按下键时,文档中插入点之后的所有字符的位置都会增加一。这意味着更新受限的区域从当前字符附近一直到文档中最后一个装饰的末尾。这在结尾处很有帮助,但在开头处则完全没有帮助。幸运的是,这很容易修复。TextDelta 类在其构造函数中接收两个字符串,并找到第一个差异以及与文本更改相关的偏移量。请注意,我利用了不可能通过键盘和鼠标进行非连续的单次编辑这一事实。如果我们对之前的 TextIndex 应用非常简单的 shift 函数

public void Shift(int startingIndex, int amount)
{
    foreach (TextIndex t in this)
    {
         if (t.Contains(startingIndex ) )
                {
                    t.Length  +=amount;
                }
                else if (t.Start > startingIndex)
                {
                    t.Start += amount;
                }
    }
}

期望的性能提升出现了,我们得到了一个足够优化的控件,可以实际使用。(请注意,这是“非常简单的 shift 函数”的更新版本。原始版本有一个错误。)

结论

本文的目的是既要介绍一个有用的控件,又要使其易于理解。我相当确定前者是成功的,但后者我有些疑问。优化的代码通常是神秘的。我们发现了一些例程,它们仅用于在各种特殊情况下提高速度。如果不了解历史,我们常常会想,这种奇怪的构造是因为前一个程序员不知道自己在做什么,还是热爱复制粘贴。我不想把这段代码表现得好像它是处理这种情况的显而易见的方式。也许是,但对我来说肯定不明显。希望这个简短的关于一个 textbox 的转变过程的故事,它从每按一次键需要大约两分钟(对于大文件)到大约 0.03 秒(快了 4000 倍),能够提供一些帮助。我还想特别感谢 Arthur Jordison,他的鼓励使这个项目得到了足够的重视而得以完成。

更新

2000/11/1 - Bug 修复

TextIndexList 中的 Shift 方法未能考虑 shift 的起始点可能位于 TextIndex 内的可能性。现在可以了。

2000/11/11 - Bug 修复

已修复粘贴、撤销以及在可变长度装饰内进行的更改的问题。这显示了撤销功能的一个严重缺陷,目前正在纠正。完成后,我将对我的解释进行详细更新。

© . All rights reserved.