FastColoredTextBox中文修正版V2:WinForms光标高亮修复 简介FastColoredTextBox中文修正版V2面向需要在WinForms项目中集成代码编辑与语法高亮控件的C#开发者重点解决原版在中文双字节场景下显示异常、光标定位偏移以及多Style并存时样式错位等问题适合用于代码查看器、配置编辑器和轻量级开发工具界面。压缩包共463个文件、约17.01MB包含125个cs源码、94个resources资源、61个resx资源定义、40个vb示例以及28个png、14个dll等配套文件并带有sln、csproj、vbproj和多套sample工程便于直接编译、对照调试与二次开发。已有1763人学习下载说明其在中文高亮文本框改造场景中具有较好的参考价值。读者可获得可运行的修正版源码、示例工程和资源文件用于排查中文字体渲染、光标坐标换算与多样式切换问题并在此基础上快速搭建代码查看器、配置编辑器或小型IDE界面。1. 光标总比文字慢半拍这个修正版到底改了什么先说个具体场景。WinForms 项目里要嵌一个代码编辑器能开几万行配置、要行号要折叠要查找替换拖一个 FastColoredTextBox 上去英文代码跑得顺顺当当换成中文注释插入符立刻开始出问题。点在第 20 个汉字后面光标线画在第 14 个汉字底下横向滚动几像素高亮色块和文字错开一截输入法候选框弹出在窗口左上角。FastColoredTextBox 中文修正版 V2 就是冲这串问题来的控件对外 API 基本没动动的是宽度测量、光标定位、语法高亮区间这三处底层逻辑。它适合谁手里已经有 WinForms 项目、想低成本塞进一个编辑器又不想换框架的人。它不适合谁不启用中文内容、也不需要 IME 输入的人大可不必换。下面按“先看清结构、再跑起来、最后改得动”的顺序拆。2. 项目定位与技术栈它解决的并不只是“能不能显示中文”2.1 真正的病灶在像素层不在字符集层很多人第一次遇到“中文显示成方块”第一反应是编码错了把文件读成 GBK 再转 UTF-8转完发现显示还是歪。这两件事完全不在一个层面上。字符能不能出来取决于字体和编码字符摆得正不正取决于控件在绘制时怎么算出每个字的宽度。FastColoredTextBox 的设计前提是等宽字体。它内部有一个CharWidth字段初始化时拿一个W字测出宽度之后所有列坐标、缩进、光标横坐标都按“字符序号 × CharWidth”推算。英文等宽字体下这个假设成立得离谱一个字符 8 像素就是 8 像素。中文字符在同样的字号下通常是 12 到 16 像素全角标点跟汉字等宽半角标点又是另一个值。假设一破后面所有的坐标计算全部连锁偏移光标偏左、选区框短一截、行号与内容对不齐、折叠标记落不到行首。所以修正版 V2 干的活本质是把“一个全局常量”换成“一张按字符查的宽度表”再把所有依赖这个常量的地方逐个换掉。这是典型的血泪经验题改一处没用得把用它的十几处全找出来。2.2 技术栈与运行前提这份源码的依赖面很窄这一点对二次开发是大好事。层面选型说明运行时.NET Framework 4.x老版本也能挂但泛型与 LINQ 用得多4.0 以下别试UI 框架WinForms纯 GDI 自绘不依赖 WPF不依赖第三方 UI 库渲染GDI /Graphics文本绘制、双缓冲、滚动条都走 WinForms 原生路径文本模型UTF-16 内存字符串内部按char索引注意代理对与小字符集问题工程形态类库 Demo 可执行类库供引用Demo 供验证依赖窄意味着两件事。一是编译期几乎不会出现“找不到包”的情况拉下来直接能开。二是它跟现代 .NET 的兼容要自己做工作System.Drawing.Common在非 Windows 平台上的表现、TextRenderer在不同目标框架下的可用性都得单独验。我一般会把它当成“Windows-only 组件”对待别指望跨平台跑。2.3 跟 TextBox、RichTextBox、ScintillaNET 比选它的理由原生TextBox只支持单一样式RichTextBox能上色但处理大文件时重绘压力大而且行号、折叠、缩进参考线这些编辑器必备件都得自己外挂。ScintillaNET 功能最全但它的封装层厚、样式系统是另一套心智模型想深度定制时反而绕。FastColoredTextBox 的甜区在中间功能密度足够语法高亮、折叠、书签、自动缩进、括号匹配、查找替换对话框、文档地图源码是纯 C# 单文件级别可读改起来不需要跨语言调试。代价是它对 CJK 的原生支持一直欠着这份中文修正版补的就是这块。如果你的项目里中文注释占比高、又要嵌在既有 WinForms 窗体里这个取舍是划算的。3. 结构拆解从 TextSource 到 SyntaxHighlighter 的一条链路3.1 目录分层五层职责划得比较干净拿到源码先别急着看主控件文件那几千行足以劝退。按职责切开看结构其实很规整。层次典型文件职责控件层FastColoredTextBox.cs输入事件、滚动、绘制入口、公开 API文档模型层TextSource.cs、Line.cs字符缓冲区、行索引、行首位置样式层Style.cs、SyntaxStyle.cs、StyleIndex.cs样式集合与区间标记高亮层SyntaxHighlighter.cs及各语言子类正则扫描文本、给区间打样式交互层FindForm、GoToForm、Hints、DocumentMap查找替换、跳转、提示、缩略图修正版新增或改动集中在文档模型层和高亮层控件层只改绘制与坐标换算的调用点。这个切分很关键你后续要加一门新语言的高亮只需要继承高亮层别去碰控件层。3.2 四个必须吃透的类TextSource是文本的真实容器内部维护一个char[]加一套行首偏移索引按行号取行比反复Split快得多。Line是行的视图带着StartPosition、缩进级别、折叠状态它本身不存字符只持有一个指向TextSource的引用和行号。Place是最常出现的结构体由iLine和iChar组成代表一个逻辑坐标——注意是“逻辑”的不是像素。SyntaxHighlighter是抽象基类核心方法HighlightSyntax(Range range)。每次文本变动后控件会算出受影响的区间交给高亮器重扫高亮器用正则找出关键字、字符串、注释等片段再用range.SetStyle(style, pattern)打标记。样式标记存在一个按字符索引的数组里绘制时逐字符查表取颜色。3.3 绘制主链路一次滚动到底发生了什么Invalidate() ↓ OnPaint(PaintEventArgs) ↓ for each visible line: DrawLine(line, g) ↓ 计算行起始 X LeftIndent - HorizontalScroll.Value ↓ 遍历该行字符按样式分段 ↓ DrawString 或 TextRenderer.DrawText 逐段绘制 ↓ DrawCaret(g) ← 光标单独绘制坐标来自同一套宽度计算这条链路是中文修正的关键位置DrawLine里算出的每段起始 X和DrawCaret里算出的光标 X必须用同一个函数否则必然错位。很多改到一半翻车的版本就是只修了绘制没修光标或者反过来。下面一章专门拆这块。4. 中文修正的核心实现宽度表、光标坐标与高亮区间4.1 把 CharWidth 从常量换成查表函数第一步是废掉那个全局常量。常见做法是建一个按char索引的宽度缓存数组首次遇到某个字符时用TextRenderer.MeasureText实测并缓存之后直接查表。// 字符宽度缓存按 char 索引首次测量后复用 private readonly Dictionarychar, int _charWidthCache new Dictionarychar, int(); private int _defaultCharWidth; // 等宽英文字符的基准宽度 // 判断是否需要按全角/宽字符处理 private static bool IsWideChar(char c) { // CJK 统一表意文字、全角标点、假名、谚文等区间 return (c 0x1100 c 0x115F) // 谚文字母 || (c 0x2E80 c 0xA4CF) // CJK 部首、假名、汉字 || (c 0xAC00 c 0xD7A3) // 谚文音节 || (c 0xF900 c 0xFAFF) // CJK 兼容汉字 || (c 0xFE30 c 0xFE4F) // CJK 兼容标点 || (c 0xFF00 c 0xFF60) // 全角 ASCII || (c 0xFFE0 c 0xFFE6); // 全角符号 } // 取单个字符的显示宽度单位像素 public int GetCharWidth(char c) { if (c \t) return _defaultCharWidth * TabLength; // 制表符按倍数 if (c 0x80) return _defaultCharWidth; // ASCII 走快路径 int w; if (_charWidthCache.TryGetValue(c, out w)) return w; // 实测用控件的当前字体和缩放环境测一个字符 using (var g CreateGraphics()) { w TextRenderer.MeasureText(g, c.ToString(), Font, new Size(int.MaxValue, int.MaxValue), TextFormatFlags.NoPadding).Width; } _charWidthCache[c] w; return w; }这段代码有两处值得说。IsWideChar不是必需的它的作用是给“宽度明显大于基准值”的字符一个快速判定避免每个汉字都去走一次MeasureText在几万行的文件里这个取舍影响很大。TextRenderer.MeasureText用了NoPadding不加这个标志它会带出额外的边距测出来的宽度普遍偏大两三像素累积到行尾就是几十像素的偏差。_defaultCharWidth的初始化也要改。原来拿W测改成拿M和0各测一次取较大值或者直接用Font.Height换算。这一步的差异在不同字体下能差出 1 到 2 像素别小看它会被乘上列数。提示_charWidthCache必须随Font变化清空。用户切换字号后如果不清宽度全是旧值表现为“字变大了但光标位置没变”。4.2 光标与选区定位从乘法改成累加光标横坐标原先是X LeftIndent p.iChar * CharWidth改成从行首累加到目标列// 计算某个逻辑位置对应的像素 X 坐标 public int GetPositionX(Place p) { int x LeftIndent - HorizontalScroll.Value; string line this[p.iLine].Text; // 当前行的文本 int limit Math.Min(p.iChar, line.Length); for (int i 0; i limit; i) { x GetCharWidth(line[i]); // 逐字符累加中文自然多占 } return x; }逐字符累加是 O(n) 的一行几千字符时会被频繁调用所以实际实现里通常再加一层“行内前缀宽度缓存”每行维护一个int[]记录每个位置的累计 X文本变动时只重算受影响的行。这一步不做拖动选区时会明显卡顿属于典型的踩坑点。选区绘制用的是同一套函数起点和终点各算一次中间用矩形填充。光标的竖线高度取Font.Height而不是硬编码否则换字号后光标比字短一截。输入法候选框的定位是另一个坑。WinForms 在接管 IME 时需要告诉系统插入符的屏幕坐标这个坐标原本也是按字符数算的。修正方案是在处理WM_IME_STARTCOMPOSITION消息时用GetPositionX算出行内 X再用PointToScreen转换把组合窗口摆到光标正下方。不改这里的表现就是候选框固定在窗口左上角用户根本没法用输入法打字。4.3 样式错位区间索引必须跟字符索引一致样式错位的根因通常不在绘制而在高亮器打标记时用错了索引单位。老代码里如果有按字节切片的正则处理或者把 UTF-8 字节偏移当字符偏移用中文一来就整体右移。因为一个汉字在 UTF-8 里是 3 字节、在 UTF-16 里是 1 个char两套索引混用偏移量越往后差得越多。修正的做法是统一到char索引Range的起点和长度全部以char计。高亮器扫描时拿到的也是字符串本身正则引擎按字符匹配天然对齐。public override void HighlightSyntax(Range range) { // 清空旧样式避免重扫后残留 range.ClearStyle(StyleIndex.AllSet); // 关键字注意用 \b 边界中文相邻时 \b 行为要注意 range.SetStyle(BlueStyle, \b(if|else|for|while|return|class)\b); // 字符串允许内部含中文 range.SetStyle(RedStyle, [^\\]*(\\.[^\\]*)*); // 单行注释中文注释是这个修正版的重点场景 range.SetStyle(GreenStyle, //.*$, RegexOptions.Multiline); // 数字 range.SetStyle(MagentaStyle, \b\d(\.\d)?\b); }SetStyle的第二个参数是正则第三个是选项。这里要留意\b在中英文混排时的行为汉字在 .NET 正则里被视为单词字符所以if中文这种紧贴写法不会被\b切开这通常是想要的效果。如果你发现关键字高亮串到了中文上先查正则边界别急着怀疑坐标。另一个常见错位源是折叠标记和缩进参考线。它们按行首 X 绘制而行首 X 又依赖缩进字符的宽度。缩进如果是空格没问题如果是 Tab就得保证GetCharWidth(\t)和绘制时 Tab 的实际展开宽度一致。这两处不一致的表现是缩进参考线画在文字左边或右边一点肉眼一看就是歪的。4.4 自动换行与长文本别在汉字中间断开WordWrap打开后原来的断行逻辑是按“可用宽度 / CharWidth”算出每行能放多少字符然后硬切。中文没有空格硬切的位置往往在词中间视觉上很别扭而且切点在宽度表启用后会算错。修正版一般改成按累计宽度断行从左往右累加字符宽度超过可用宽度就在上一个“可断点”处切。可断点的判定是遇到空格、标点、或者中文字符边界。中文按字断是可接受的但至少不能把一个全角标点拆成两半导致半个字形被裁掉。顺便提一句大文件。几万行的文本如果每次编辑都全量重扫高亮输入会明显掉帧。合理的做法是只重扫受影响的行区间加上一个延迟触发的定时器用户停止输入 200 毫秒后再重扫。这个延迟值可以做成属性暴露出去不同机器手感不一样。5. 编译与避坑从工程配置到线上排查的 5 条记录5.1 拉下来怎么跑起来工程是标准类库加 Demo 的结构编译顺序上先出类库再出 Demo。命令行下大致是这样# 还原并编译类库Debug 便于附加调试 msbuild FastColoredTextBox.csproj /p:ConfigurationDebug /p:PlatformAny CPU # 编译 Demo 宿主程序 msbuild Demo.csproj /p:ConfigurationDebug # 运行 Demo验证中文输入与光标 ./bin/Debug/Demo.exe如果手上只有新版构建工具msbuild找不到老工程格式是常见的用 Visual Studio 打开解决方案文件让它自动做一次工程升级最省事。升级前先把整个目录复制一份升级动作会改写工程文件出问题好回退。Demo 跑起来之后按这个顺序验粘贴一段中英混排的代码点几个不同位置看光标落点切换到输入法打几个汉字横向滚动到底再看一次光标打开自动换行再重复一遍。四步全过基本说明这套修正生效了。5.2 常见问题排查五个真实翻车点现象一中文能显示但整行文字向右偏越靠右偏得越多。原因通常是宽度表只改了绘制、没改坐标计算或者只改了DrawLine没改DrawCaret。也可能是_charWidthCache在字体切换后没有清空用的是旧字体测出来的宽度。解决是把所有 X 计算收敛到一个GetPositionX里绘制、光标、选区、折叠标记、缩进线全部调它不许有第二套算法。字体变更事件里显式_charWidthCache.Clear()并重算_defaultCharWidth。现象二输入法候选框弹在窗口左上角或者根本不跟随光标。原因是控件没有处理WM_IME_STARTCOMPOSITION和WM_IME_COMPOSITION消息系统拿不到插入符位置。WinForms 自绘控件如果不主动上报IME 只能用一个默认坐标。解决是重写WndProc在这两个消息到达时算出光标屏幕坐标通过ImmSetCompositionWindow把组合窗口位置设过去。坐标一定要用跟绘制同一套的GetPositionX用两套算法必然对不齐。现象三语法高亮整体右移一到两个字符注释色块盖到了代码上。原因是高亮器用了字节偏移或Substring之后又按长度回推位置中文字符导致索引不一致。也可能是正则里用了\w之类的匹配中文被算进单词后区间变长。解决是把所有区间计算统一到char索引禁止任何地方出现“字节长度”这个词。排查时可以在SetStyle里临时打日志把区间起点和对应文本打出来一眼就能看出偏在哪个字符上。现象四滚动之后出现重影文字叠着文字。这类问题多半不是中文引起的是双缓冲没开或者重绘区域算错。修正版引入了新的宽度计算后HorizontalScroll的最大值如果还按旧的字符数 × CharWidth算滚到某个位置会算出负数宽度绘制区域错乱。解决是把滚动范围的计算也换成累计宽度并确保DoubleBuffered true、SetStyle(ControlStyles.OptimizedDoubleBuffer, true)已设置。滚动条最大值取整行宽度的最大值别用平均值。现象五几十万行的大文件打开就卡死或者输入一个字要等两秒。原因是全量重扫高亮加全量重绘。中文宽度表本身不慢慢的是每输入一个字符就把整个文档重新扫一遍正则。解决是只重扫改动所在的行区间加输入延迟定时器滚动时只绘制可见行。另外宽度缓存用Dictionarychar,int在超大文本下也要留意命中率——中文常用字几千个缓存规模可控问题不大如果你的场景里有大量生僻字或组合字符考虑加个上限做 LRU。6. 二次开发把它改成你自己的编辑器真到落地阶段改动通常集中在三处加一门自己的语言高亮、调性能、接业务数据。加语言高亮不用碰控件层继承SyntaxHighlighter重写HighlightSyntax就行。下面是个最小可用的自定义高亮器针对一种假想的配置格式public class MyConfHighlighter : SyntaxHighlighter { // 样式对象在构造时建好别在每次高亮里 new否则 GC 压力大 private readonly SyntaxStyle _keyStyle; private readonly SyntaxStyle _valueStyle; public MyConfHighlighter(FastColoredTextBox editor) : base(editor) { _keyStyle new SyntaxStyle(Color.SteelBlue, Color.Transparent, FontStyle.Bold); _valueStyle new SyntaxStyle(Color.SeaGreen, Color.Transparent, FontStyle.Regular); } public override void HighlightSyntax(Range range) { range.ClearStyle(_keyStyle, _valueStyle); // key value 形式key 限定为字母数字下划线避免中文键名误伤 range.SetStyle(_keyStyle, ^\s*([A-Za-z_][A-Za-z0-9_]*)\s*, RegexOptions.Multiline); range.SetStyle(_valueStyle, \s*[^\r\n]$, RegexOptions.Multiline); } }挂上去只要一行editor.Language Language.Custom; editor.Highlighter new MyConfHighlighter(editor);。注意赋值顺序先设Language再设Highlighter反过来会被默认高亮器覆盖掉。性能上最值得做的一件事是给宽度加行内前缀缓存。做法是Line里加一个int[] PrefixWidth长度等于行长加一文本变动时把该行标脏访问GetPositionX时如果脏就整行重算一次。这一步做完拖动选区从肉眼可见的卡顿变成顺滑成本也就几十行代码。验证方法我一般用三组数据纯英文代码、纯中文注释、中英数字标点混排各一份每份都走“点击定位—输入法打字—横向滚动—自动换行”四步再看选区、折叠标记、缩进参考线是否跟文字严格对齐。这个验证清单比读代码可靠得多因为错位的表现是视觉的只能靠眼睛验收。最后说个教训。我最早改这类自绘控件时习惯性地只改看得见的那条路径——绘制不对就改绘制改完发现光标还是偏再改光标发现选区框又短了。来回折腾两天才想明白问题不在某一处画错了而在“同一个位置”被三种不同的算法各算了一遍。从那以后我每次碰坐标相关的修正都强制先做一件事把“从逻辑位置到像素坐标”的换算抽成唯一入口全局搜索有没有第二处乘法删干净了再动手。这个习惯省下的时间比任何一次具体的调参都多。希望帮到你。本文还有配套的精品资源点击获取