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

CodeBox 2:带行号的 CodeBox 的扩展和改进版本

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.83/5 (20投票s)

2009 年 4 月 20 日

CPOL

9分钟阅读

viewsIcon

146625

downloadIcon

3491

一个支持行号、高亮、下划线和删除线的 WPF 文本框。

引言

本文介绍了我之前文章中 CodeBox 的增强版本。它现在具有行号和更高效的渲染。现在只渲染可见文本。除了单词着色、高亮、删除线和下划线等装饰之外,现在还支持用作进一步装饰基础的装饰方案。例如,我们现在可以有一个 C# 装饰方案,然后在此基础上添加高亮。

示例应用程序是一个简单的文本编辑器,支持装饰方案和文本着色。它还允许您将显示的代码快照保存为图像文件。我希望制作一个简单但至少有些有用的应用程序来展示改进后的 CodeBox 能做什么。

主要思想

CodeBox 是 WPF 文本框的增强版本,允许更大程度的视觉定制,同时尽可能保留原始文本框的功能。它源于以下观察:

  • 我们可以在文本框表面上进行渲染,同时保留其其余功能。这在第一篇文章中有所介绍。
  • 我们可以创建一个表示装饰的类层次结构,例如文本着色和高亮。这也已在上一篇文章中有所介绍。
  • 装饰可以分为两种类型:一种是数据类型固有的(BaseDecorations),另一种是文档特有的装饰。
  • 行号和其他类型的标签可以通过修改 CodeboxControlTemplate 来添加。这允许我们同时拥有标准的文本渲染区域和行号区域。
  • 我们还可以通过仅渲染 Codebox 中当前可见的文本来提高效率。以前的版本渲染所有文本,直到当前查看的文本。这使其仅适用于短文档。

背景

这个 CodeBox 是我在 CodeBox 文章中介绍的 CodeBox 的主要修订和升级。尽管此版本改进了很多,而且绝对是我建议您用于您的目的的版本,但阅读原始版本并检查其代码应该会使其更容易理解。

在创建此控件时,我发现许多我希望阅读的关于 TextBox 的文档根本不存在。希望这能弥补这个不足。我将使用以下格式:

成员名称 MSDN 定义
来自 Reflector 的反汇编代码

评论

GetFirstVisibleLineIndex:返回文本框中当前可见的第一行的行索引。

public int GetFirstVisibleLineIndex()
{
    if (base.RenderScope == null)
    {
        return -1;
    }
    double lineHeight = this.GetLineHeight();
    return (int) Math.Floor((double) ((base.VerticalOffset / lineHeight) + 0.0001));
}

我们首先应该注意到测试 RenderScope 属性的那一行。RenderScope 是作为可视化树构建的一部分设置的。据我所知,RenderScope 属性在设计模式下将始终为 null。像下面这样的单元测试将始终失败:

[Test]
public void LineCount_Test()
{
    TextBox tx = new TextBox();
    tx.Text = "1\n2";
    tx.Height = 200;
    tx.Width = 200;
    Assert.AreEqual(2, tx.LineCount);
}

这和许多变体都会失败,因为 RenderScopenull。还值得注意的是,私有方法 private GetLineheight 仅取决于字体大小和字体系列。没有参数表示行距。

LineCount:获取文本框中的总行数。

[DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)]
public int LineCount
{
    get
    {
        if (base.RenderScope == null)
        {
            return -1;
        }
        return (this.GetLineIndexFromCharacterIndex
			(base.TextContainer.SymbolCount) + 1);
    }
}

LineCount 只是文本末尾的行。这是文本框中出现的行数,因此会随文本框的宽度而变化。此方法不可靠。在 Text 属性更改后,它将取值 0。请注意,这意味着它不能响应 TextChanged 事件调用。

没有内置的最小行数功能,将 RexrWrapping 设置为 None 应该会得到相同的结果。以下函数将起作用,并说明了用于确定行号的规则:

public int MinLineCount(TextBox tx)
{
    string str = tx.Text;
    // \r\n represents one, not two linebreaks
    str = str.Replace("\r\n", "\n"); //Make all line delimiters 1 character
    char[] chars = str.ToCharArray();
    int breakCount = 0;
    //lineBreaks are the list of special character that cause an additional line
    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:返回文本框中当前可见的最后一行的行索引。

public int GetLastVisibleLineIndex()
{
    if (base.RenderScope == null)
    {
        return -1;
    }
    double extentHeight = 
      ((IScrollInfo) base.RenderScope).ExtentHeight;//Last line visible box
    if ((base.VerticalOffset + base.ViewportHeight) >= extentHeight)
    {
        return (this.LineCount - 1);
    }
    return (int) Math.Floor((double) (((base.VerticalOffset + 
            base.ViewportHeight) - 1.0) / this.GetLineHeight()));
}

GetLastVisibleLineIndex 相当不可靠。在文本更改特别是按键操作后,它将取值 -1。即使 LineCount > 0,它也可能失败。

我们看到最后可见行索引有两种情况。文本的最后一行可能出现在 viewport 中,其中索引由 LineCount 确定。否则,它由 LineheightVerticalOffsetViewportHeight 计算得出。请注意,这是最后至少部分可见行的索引,而不是最后完全可见行的索引。

GetRectFromCharacterIndex:重载。返回指定字符索引处字符的边框。

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:返回包含指定字符索引的行的从零开始的行索引。

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:返回指定行上当前显示的文本。

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;
}

代码

渲染

此版本的 CodeBox 为了提高效率,只渲染当前可见的文本。textbox 提供了一些方法可以用于确定可见文本及其位置。不过,主要有两个问题。首先,用于获取此信息的方法在设计器中不起作用。其次,这些方法不可靠。当它们未能提供值时(通常发生在文本更改时),控件将不得不重复上次渲染,然后稍后再次尝试渲染。

为了渲染文本,我们需要知道两件事:要使用的文本,以及它向上或向下滚动了多少。可见文本的创建如下:

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;
    }
}

我们得到第一条可见行的第一个字符,最后一条可见行的最后一个字符,文本必须是它们之间的一切。值得注意的是,GetFirstVisibleLineIndexGetLastVisibleLineIndex 比较宽松。即使它们分配的一些空间是可见的,也可以声明行可见,尽管人眼无法检测到。

为了渲染文本,我们还需要知道的另一件事是我们将用于渲染的起点。

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;
    }
}

这段代码看起来很臃肿。计算只需要两行。

Rect cRect = GetRectFromCharacterIndex(firstChar);
Point  renderPoint = new Point(cRect.Left, cRect.Top);

出于渲染目的,原点是第一个可见字符的左上角。其余代码是为了处理 textbox 并非始终准备好提供该信息的事实。我怀疑底层正在异步处理事情,但我们在 Reflector 中看到的代码相当复杂。

最后,我们来到 CodeBox 控件的 main 方法:OnRenderRuntime

protected void OnRenderRuntime(DrawingContext drawingContext)
{
   drawingContext.PushClip(new RectangleGeometry(new Rect(0, 0, this.ActualWidth, 
                           this.ActualHeight)));//restrict drawing to textbox
   drawingContext.DrawRectangle(CodeBoxBackground, null, new Rect(0, 0, 
                  this.ActualWidth, this.ActualHeight));//Draw Background
   if (this.Text == "") return;

       int firstLine = GetFirstVisibleLineIndex();// GetFirstLine();
       int firstChar = (firstLine == 0) ? 0 : 
           GetCharacterIndexFromLineIndex(firstLine);// GetFirstChar();
       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);  //Text that matches the textbox's
       formattedText.Trimming = TextTrimming.None;

       ApplyTextWrapping(formattedText);

               Pair visiblePair = new Pair(firstChar, visibleText.Length);
               Point renderPoint =   GetRenderPoint(firstChar);
               
                //Generates the prepared decorations for the BaseDecorations
               Dictionary<EDecorationType, Dictionary<Decoration, 
                          List<Geometry>>> basePreparedDecorations 
                   = GeneratePreparedDecorations(visiblePair, 
                     DecorationScheme.BaseDecorations);
               //Displays the prepared decorations for the BaseDecorations
               DisplayPreparedDecorations(drawingContext, 
                              basePreparedDecorations, renderPoint);

               //Generates the prepared decorations for the Decorations
               Dictionary<EDecorationType, Dictionary<Decoration, 
                          List<Geometry>>> preparedDecorations 
                   = GeneratePreparedDecorations(visiblePair, mDecorations);
               //Displays the prepared decorations for the Decorations
               DisplayPreparedDecorations(drawingContext, 
                      preparedDecorations, renderPoint);

               //Colors According to Scheme
               ColorText(firstChar, DecorationScheme.BaseDecorations);
               ColorText(firstChar, mDecorations);//Colors According to Decorations
               drawingContext.DrawText(formattedText, renderPoint);

               if (this.LineNumberMarginWidth > 0)
               //Are line numbers being used
               {
                   //Even if we gey this far it is still 
                   //possible for the line numbers to fail
                   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));
                   }
               }

           //Cache information for possible renderer
            renderinfo.BoxText = formattedText;
            renderinfo.BasePreparedDecorations = basePreparedDecorations;
            renderinfo.PreparedDecorations = preparedDecorations;
       }

我已经看了这段代码太久了,它看起来不言自明,但这里是它所做的事情的要点:

  • 渲染背景
  • 确定可见文本
  • 创建用于显示文本和创建装饰的 FormattedText
  • 获取渲染点
  • 创建并显示 DecorationScheme 的装饰
  • 创建并显示 Decoration 的装饰
  • 执行并显示 DecorationScheme 的文本着色,然后是 Decoration 的文本着色
  • 创建行号
  • 缓存 RenderInfo 数据,以便可以快速重复此渲染

行号

添加行号首先需要我们更改 CodeBoxControlTemplate,然后生成行号。

行号 XAML

为了给文本框添加边距,ControlTemplate 的控件部分从

<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 >

最重要的一点是,行号列宽度的 ModeOneWay。将其保留为默认值 (TwoWay) 会导致性能不佳。我们还可能会遇到控件模板无法识别的问题。创建然后修改用户控件是一种奇怪但有效的解决方法。

生成行号

有两种不同的行号场景:换行和不换行。在这两种情况下,都会创建一个带有 \n 字符的 string 来分隔行。然后,这用于创建表示行号的格式化文本对象。如果不换行,创建此 string 相当简单。

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();

换行的情况更复杂。它涉及三种方法:

  • MinLineStartCharcterPositions -- 仅由字符确定的行起始字符位置
  • VisibleLineStartCharcterPositions -- 文本框中行起始字符位置
  • lineNumberString -- 通过合并 MinLineStartCharcterPositionsVisibleLineStartCharcterPosition 得到的 string

文本框比任何文本行都宽。当文本框比最长行窄时,就会发生换行,并且 VisibleLineStartCharcterPositions 中会出现一些不在 MinLineStartCharcterPositions 中的额外元素。MinLineStartCharcterPositions 中的所有元素都应该出现在 VisibleLineStartCharcterPositions 中,外加一些额外的元素。然后,我们应该能够沿着 VisibleLineStartCharcterPositions 向下,检查 MinLineStartCharcterPositions 中的匹配项,从而获取行号。如果有人感兴趣,请评论,我将添加更详细的解释。

LineNumbers 方法使用 合并算法,该算法在处理排序列表时效率很高。该方法大约有 70 行长,所以我不打算在这里详细介绍。不过,它有充分的文档说明。

Using the Code

对于 CodeBox 类中的 900 多行代码,它毕竟只是一个 TextBox。一旦添加了适当的命名空间和引用,就可以直接使用它了。有两个注意事项:您应该将 CodeBoxBackground 用于 Background,将 BaseForeground 用于 ForeGround。如果您不想要行号,请将 LineNumberMarginWidth 设置为 0DecorationSchemes 类包含两个不完整的装饰方案,一个用于 SQL Server 2008,一个用于 C# 3.0。它们可以作为如何定义装饰的良好示例。

示例应用程序 - ColoredWordPad

作为一个示例应用程序,我创建了一个非常简单的文字处理器,它允许我们对文本进行装饰。因为我想让它有用,所以它有一个非平凡的功能。显示的文本可以导出为图像文件,不带插入点行。同样,如果有人感兴趣,请告诉我,我将添加更详细的解释。

更新

10/9/2009

  • 新增了两种额外的装饰类型。DoubleRegexDecoration 使用一对正则表达式来确定选定区域,这在第一个正则表达式定义容器而第二个定义该容器中的特定项的情况下非常有用。RegexMatchDecoration 使用正则表达式及其匹配组名称之一来指定位置。
  • 新增了三种装饰方案,用于 XAML、XML 和 DBML。XAML 和 XML 方案广泛使用了 DoubleRegexDecorationRegexMatchDecoration。XML 方案应该很合适,但是 XAML 方案仍然需要能够处理嵌套标记表达式。还有一个 DecorationSchemes 类型转换器。
  • 如果您想查看 CodeBox 的实际运行情况,可以访问这个 ClickOnce 安装站点查看我的 控件模板查看器

结论

CodeBox 的版本 1 应该被视为概念验证,而此版本可能应该被视为控件的 alpha 发布。希望此版本足够好,可以在您的应用程序中使用。

© . All rights reserved.