STTextBox - 一个纯GDI开发的开源WinForm控件

简介

STTextBox - 一个纯GDI开发的开源WinForm控件_第1张图片

STTextBox是一个开源的WinFrom控件,是在我朋友的支持下完成的,纯GDI绘制,支持Emoji表情符号、所有颜色支持Alpha、并且支持自定义文本样式等。本应当还有更多的功能,但是由于一些其他原因暂停了开发,并删除了一些代码后发布至了GitHub

STTextBox采用MIT开源协议,项目地址:STTextBox

本想上传至Nuget,想一想算了,毕竟很多功能实现我自己都不满意。主要在于渲染速度,在开发过程中我一直对标Notepad++,后来发现,算了算了,我这只是一个文本框控件而不是文本编辑器,定位搞错了,而且Notepad++采用的是Skia进行渲染,GDI根本就不是对手。Skia是谷歌开发的一个基于OpenGL的2D渲染引擎,也是谷歌浏览器和安卓手机的渲染内核。 虽然我们可以选择使用Skia,但是在这个项目中我们拒绝使用任何第三方库,所有代码全部自己编写。因为编写它的目的有两点。

  1. 以前我说过,在所有的自定义控件中最难编写的就是文本框控件,如果能手撸一个文本框控件,那么基本上没有什么控件是写不出来的了。
  2. 因为在另一个项目中我需要一个文本框控件,而我又无法使用原生的文本框控件。因为我在这个项目中装了一个逼:STNodeEditor

此代码使用.Net3.5+VS2010构建,所以我想几乎可以兼容任意版本的VS。其中遇到的一些坑在下面的内容说明

衍生项目

在开发过程中额外产生了另外两个开源项目:

emoji-svg-render:一个专为Emoji设计的SVG渲染器,也是纯GDI绘制,由于我们决定采用的是平面风格的Emoji表情比如Twemoji(twitter) Openmoji等,他们是没有渐变的,所以我做的SVG渲染器并不支持渐变和矩阵运算等,具体可以看项目详情。

STGraphemeSplitter:字素簇处理,用于获取一个完整的字符,因为在.Net中一个字符串或一个字符采用的是unicode编码存储的,一个unicode两字节,虽然能存储很多字符了,但是有些特殊字符一个unicode编码是无法保存的,比如有些Emoji甚至需要8个unicode字符,所以你认为str.Length = 1其实str.Length = 8
同时还有另外一个项目WordSplitter就懒得上传GitHub了看名字就知道是用于获取一个完整单词的,比如在文本框中双击的时候选中当前单词。

代码结构

由于能力太菜很多功能无法很好的实现,所以很多比较有代表性的功能抽取成了独立的接口,并在接口中实现。这样的话如果有能力的开发者愿意去扩展他就不用在整个项目中去寻找并修改代码,仅仅只需要创建一个新的class然后继承对应的接口实现就可以了。比如想把GDI渲染换成Skia渲染,实现ITextBoxRender接口就可以了,里面包含了STTextBox中使用到的所有绘图函数(其实也就几个绘图函数)。

class STTextBox
{
    /*
    * Core is the core of STTextBox, which contains almost all the functions of STTextBox. 
    * Because the our ability is limited, many functions cannot be implemented efficiently, 
    * so it becomes an independent interface.
    *
    * Core是STTextBox的核心,里面包含了STTextBox几乎所有的功能,因为我们能力有限,
    * 很多功能无法高效的实现,所以独立成了接口。
    */
    public class Core
    {
        // save all the textline in manager
        public TextManager TextManager { get; private set; }
        // Used to get a complete character, such as Emoji may require multiple unicode combinations.
        // 用于获取一个完整的字符,比如Emoji可能需要多个unicode组合。
        public ITextBoundary IGraphemeSplitter { get; internal set; }
        // Used to get a word, like double click.
        // 用于获取一个完整的单词,比如双击的时候。
        public ITextBoundary IWordSplitter { get; internal set; }
        // All drawing functions used by STTextBox, if you need to switch other drawing engines, 
        // you can implement this interface. GDI+ is used by default.
        // STTextBox所有用到的绘图函数,如果需要切换其他绘图引擎可实现此接口。默认使用GDI+。
        public ISTTextBoxRender ITextBoxRender { get; internal set; }
        // If you need to display Emoji expressions, you need to implement this interface,
        // which has been implemented in-built.
        // 如果需要显示Emoji表情需要实现此接口,已经内置实现。
        public IEmojiRender IEmojiRender { get; internal set; }
        // Text box view, all rendering logic is implemented in this interface.
        // 文本框视图,所有渲染逻辑在此接口中实现。
        public ITextView ITextView { get; internal set; }
        // Used to save operation history.
        // 用于保存操作历史记录。
        public ITextHistory ITextHistory { get; internal set; }
        // Used to style text.
        // 用于设置文本样式。
        public ITextStyleMonitor[] ITextStyleMonitors { get; internal set; }
        /*some other code*/
        internal Core(STTextBox textBox) {
            // by default unicode 14.0 is used.
            // https://www.unicode.org/reports/tr29/#Grapheme_Cluster_Boundary_Rules
            this.IGraphemeSplitter = new GraphemeSplitter();
            // by default unicode 14.0 is used.
            // https://unicode.org/reports/tr29/#Word_Boundary_Rules
            this.IWordSplitter = new WordSplitter();
            // Caching can speed things up, but also requires some memory.
            //GraphemeSplitter.CreateArrayCache();
            //WordSplitter.CreateArrayCache();
            // by default GDI+ is used.
            this.ITextBoxRender = new STTextBoxGDIPRender();
            // by default nowrap view is used.
            this.ITextView = new NoWrapTextView();
            // by default just save 10 steps in memory.
            this.ITextHistory = new TextHistory(10);
            /*some other code*/
        }
    }
    /*some other code*/
}

测量文本

如果要编写一个文本框,首先需要的就是绘制文本,很幸运Graphics.DrawString(...)就能帮我们做到这一点。但是有一个很关键的问题,那就是文本测量。将文字绘制出来是很容易的一件事情。但是如何知道每个字符所在的位置?想一想出了文字以外我们是不是还需要光标?如何确定光标的位置就必须知道每个字符所在的位置。

GitHub上有个非常火的项目FastColoredTextBox但是它并不支持非等宽字体,所以它几乎无需测量直接通过[width * index]就能确定字符的位置

虽然有SizeF Graphics.MeasureString(...)可以帮我们进行测量,但是很遗憾,它是测量一个字符串的大小,依然无法得到每个字符的位置。但是很幸运还有一个方式,Region[] Graphics.MeasureCharacterRanges(...)可以帮我们得到每个字符所在位置,虽然它一次只能计算32个字符,但是我们可以通过轮询的方式处理。一开始我们也打算这么干,可是后来发现不行,为什么?

字素簇

什么是字素簇?简单来说就是人类意识中的单个字符。什么意思?也就是说有一些字符在人类看来就是一个字符,而是对于程序来说确是多个字符,比如最直接的换行,无论它是\r\n还是\n在人类看来它都是一个换行,想象一下这个字符串"aa|\r\nbb",其中|是光标当前位置,现在用户按下方向键右,那么应该怎么处理?我想应该是变成"aa\r\n|bb"而不是"aa\r|\nbb"。而在unicode的文档中确实也是将\r\n定义成了一个整体,所以在处理的时候我们也应当将它看作整体。字素簇应当是文本处理的最小单元。

关于更多字素簇的内容可以参考我的这篇文章:在C#中处理字符簇

效率

所以Graphics.MeasureCharacterRanges也无法帮助我们了,最后我们做了一个大胆的决定,使用Graphics.MeasureString逐字符测量。虽然可能效率很糟糕,但是我们可以通过字典缓存来解决效率问题,因为文本中其实有很多字符都是重复的没必要每次都去测量,已经测量个的可以用一个字典保存起来,这样效率可以大大的提高。 而真正拖效率的不是逐字测量,而是逐字绘制。 为什么?因为GDI的文本测量精度非常低,一个有10个字符的字符串测量结果是[每个字符的宽度和] != [整个字符串的宽度],所以无法整句绘制逐字测量,数据会对不上。

1440*900分辨率下STTextBox采用10号字体,并且文本很多行每行都很长确保中间没有空隙,屏幕中最大化的话差不多有1W个字符需要绘制,效率可想而知,当然我可以选择缓存每个绘制的行到一个Bitmap对象中,每次绘制的时候将缓存的Bitmap绘制到对应位置就好了,这样的话绘制的次数就是当前屏幕中显示的行数了,而不是当前屏幕中显示的字符数。但是这样的话会直接导致无法在颜色中使用Alpha而我又想让它支持Alpha所以并没有缓存,为什么支持透明就无法缓存了?
STTextBox - 一个纯GDI开发的开源WinForm控件_第2张图片
如上图,设置了背景,假设已经缓存了每一行并且下面还有很多其他行,现在滚动条往上滚动一格?

你可能不太理解,如果将每一行缓存在Bitmap中的话,这个Bitmap不能是透明的,你可以试一下在一个透明的Bitmap上面Drawstring()看看最后的效果,直接出毛边(当然点阵模式除外)。除非自己实现一个DrawString()函数并且自己解析字体文件,成本太大了。

IME支持

IME是什么?(input-method-editor)…就是输入法,我们在编写STTextBox的时候也会参考一些别人的开源项目,我惊奇的发现,在一些别的项目中,文本框居然不支持输入法?而当我很疑惑向朋友说明情况的时候他很惊讶问我什么是输入法?。。。。。。。。因为他并不是中国人,他在一个使用拉丁字符集的国家,所以键盘上26个字母就够用了。这也就是为什么一些开源项目中无法使用输入法的原因。。因为。他。们。根。本。不。知。道。这。个。世。界。上。还。有。输。入。法。这。个。东。西。他们仅仅通过KeyChar或者KeyDown这样的事件来获取输入。

其实要实现输入法的支持也很简单,首先你需要创建一个光标(插入符)相关的Win32函数有CreateCaret ShowCaret HideCaret SetCaretPos DistoryCaret 当UI中存在一个插入符时,系统会自动关联到输入法程序,接下来就是通过输入法接口和输入法直接对接。在Win32的一些函数中有一些ImmXXX的函数是用于IME开发的,一些函数已经在STTextBox中封装成了事件。

protected override void WndProc(ref Message m) {
    switch (m.Msg) {
        // 当输入法初始的时候
        case Win32.WM_IME_STARTCOMPOSITION:
            m_hIMC = Win32.ImmGetContext(this.Handle);
            this.OnImeStartPrivate(m_hIMC);
            break;
        // 当输入法结束的时候
        case Win32.WM_IME_ENDCOMPOSITION:
            this.OnImeEndPrivate(m_hIMC);
            break;
        // 当输入法产生一个结果的时候
        case Win32.WM_IME_COMPOSITION:
            if (((int)m.LParam & Win32.GCS_RESULTSTR) == Win32.GCS_RESULTSTR) {
                m.Result = (IntPtr)1;// 当在输入法上确认一个结果时中断WinForm的KeyXXX事件
                this.OnImeResultStrPrivate(m_hIMC, Win32.ImmGetCompositionString(m_hIMC, Win32.GCS_RESULTSTR));
                return;//Interrupt, WM_CHAR, WM_IME_CHAR messages will not be generated.
            }
            // 在输入法上产生了新的按键组合时
            if (((int)m.LParam & Win32.GCS_COMPSTR) == Win32.GCS_COMPSTR) {
                this.OnImeCompStr(m_hIMC, Win32.ImmGetCompositionString(m_hIMC, Win32.GCS_COMPSTR));
            }
            break;
    }
    base.WndProc(ref m);
}

基本上到这里就已经可以做一个文本框控件了,如果你还需要一个额外功能,那么就需要继续扩展代码。

历史记录

在一些文本编辑器中(编辑器不是文本框)中的历史记录几乎可以无限撤销我也是挺惊讶的,不知道是内存保存的还是文件保存的,在STTextBox中也提供了这样的接口,内置的实现是内存保存。

public interface ITextHistory
{
    void SetHistory(TextHistoryRecord[] histories);
    TextHistoryRecord[] GetUndo();
    TextHistoryRecord[] GetRedo();
    void Clear();
}
public class TextHistory : ITextHistory
{
    private int m_nIndex;
    private int m_nCount;
    private int m_nMax;
    private TextHistoryRecord[][] m_arr;

    public TextHistory(int nCount) {
        m_nMax = nCount;
        m_arr = new TextHistoryRecord[nCount][];
    }

    public void SetHistory(TextHistoryRecord[] histories) {
        if (m_nIndex == m_nMax) {
            for (int i = 1; i < m_arr.Length; i++) {
                m_arr[i - 1] = m_arr[i];
            }
            m_nIndex--;
        }
        m_arr[m_nIndex++] = histories;
        m_nCount = m_nIndex;
    }

    public TextHistoryRecord[] GetUndo() {
        if (m_nIndex == 0) { //not have history
            return null;
        }
        return old = m_arr[--m_nIndex];
    }

    public TextHistoryRecord[] GetRedo() {
        if (m_nIndex == m_nCount) {
            return null;
        }
        return m_arr[m_nIndex++];
    }

    public void Clear() {
        m_nIndex = m_nCount = 0;
    }
}

开发者仅仅需要考虑如何保存历史记录就可以了,上面的代码是直接保存在了内存中,并且指定了保存多少步。
你可能会问为什么历史记录一个步骤为什么会是一个数组?因为有些步骤就是要进行多次操作的,比如选中几行文本然后按下Tab按键?又或者按住Alt选中一块文本编辑?当然这个功能只是预留了,并没有实现。

文本样式

STTextBoxITextStyleMonitor用于监视文本框中的文本,并可以自定义文本样式。
STTextBox - 一个纯GDI开发的开源WinForm控件_第3张图片
SetTextStyleMonitors可支持多个样式监视器,并且优先级按照先后顺序排列,接口非常灵活,既可以简单也可以非常复杂,截图中的Demo是一个非常简单的实现,因为它仅仅需要监视文本中的StyleDemo关键字即可,然后单独为它制定文本样式。

而内置的CSharpStyleMonitor就是一个复杂的实现。可以看到所有的截图中代码高亮都非常的全面,而不仅仅是.Net语法的关键字,属性 函数 符号 数字都被高亮显示了,因为在CSharpStyleMonitor中实现了一个简单的词法分析器用于解析CSharp的语法规则。至于其他语言。。。。我累了。。。只想摆烂。。

STTextBox发布的时候内置了4个样式监视器,分别为:KeyWordStyleMonitor CSharpStyleMonitor LinkStyleMonitor SelectionStyleMonitor,其功能分别为:指定任意关键字和样式、CSharp语法高亮、超链接样式,与被选中单词一样的关键字高亮。

结束

关于更多的Demo请查看项目中的demos_bin和代码。通常。。。这个时候。。是不是因该来一句。。:老铁双击点赞666??奥利给??

https://github.com/DebugST/STTextBox

你可能感兴趣的:(GDI+,开源,C#自定义控件开发,C#,gdi/gdi+,windows)