资讯动态

FastColoredTextBox中文修正版V2:WinForms光标选区

发布时间:2026/10/27 7:32:54 来源:尧图企业网站定制
简介FastColoredTextBox 是一款广受好评的开源代码高亮文本框控件支持 C#、HTML 等多种语言的默认高亮与自定义显示模式常用于 WinForms 的代码编辑与日志着色。原版对中文双字节字符处理不佳汉字显示异常、光标位置偏移、多个 Style 并存时样式错位等问题频发V2 修正版针对上述缺陷逐项修复适合正在开发编辑器类工具的 .NET 开发者直接替换使用。压缩包共 463 个文件整体约 17.01MB主体为 125 个 cs 源码与配套的 resx、resources 资源文件另有 40 个 vb 示例、28 个 png 界面素材、14 个 dll 依赖以及 sln、csproj 工程配置控件源码与示例工程同包提供便于对照调试与二次开发。目前已有 1763 人学习下载。读者可获得可直接编译的控件源码与示例工程省去自行排查中文显示与光标定位问题的时间也可参照修复后的代码理解多 Style 混排时的绘制逻辑快速集成到自己的项目中。1. 中文代码编辑区的翻车现场原版控件为什么一遇到中文就偏我在一个桌面配置工具里嵌过代码编辑区英文脚本跑得好好的中文注释一多就开始翻车光标明明点在“配”字右边插入点却停在汉字左半边拖选三行中文高亮块却斜到下一行滚动条拖到底最后一行总差半截。FastColoredTextBox 中文修正版 V2 处理的就是这类问题重点不在“能显示中文”而在中文显示之后光标位置、选区高亮、样式着色、滚动范围仍然对得上。它适合正在用 WinForms 做脚本编辑器、日志查看器、配置文本编辑区的开发者尤其是原版控件英文正常、中文一多就坐标漂移的场景。先别急着替换控件先把测量和坐标这条链路看明白后面改起来才不慌。2. 技术栈与工程结构WinForms 控件如何拆成测量、布局、渲染三条线2.1 技术栈不是“一个 TextBox”GDI、TextRenderer 与输入法这个中文修正版 V2 的底座是 C# WinForms控件本身继承自 Control文本绘制走 GDI字符测量通常用 TextRenderer 或 Graphics.MeasureString。这里有一个血泪经验测量和绘制必须用同一套字体、同一套 DPI 缩放、同一套 TextFormatFlags否则测出来 12 像素、画出来 13 像素光标就会越走越偏。原版控件更偏向按字符数乘固定宽度来估算位置英文等宽字体没问题中文、全角标点、Tab、代理对一混进来估算值就和真实像素值分家了。修正版 V2 的思路不是把控件推倒重写而是在文本模型和绘制层之间加一条“测量—布局—渲染”链路。文本模型仍然保存字符索引、行号、样式范围测量层负责把字符索引换算成像素宽度布局层把一行文本切成可绘制的片段并缓存前缀宽度渲染层只消费布局结果不再自己临时估算。输入法组合文本、撤销重做、语法高亮样式范围仍然挂在原来的文本模型上避免改一处崩一片。技术栈上还要注意几个点。第一WinForms 的 TextRenderer 基于 GDI和 Graphics.DrawString 的 GDI 测量结果可能有差异控件里不要混着用。第二高 DPI 下要拿到当前控件的 DeviceDpi测量结果按缩放比修正不能写死 96 DPI。第三输入法组合字符串在未上屏前不应该进入正式文本模型否则光标索引会跳。常见做法是维护一个 composition 区间绘制时叠加显示提交后再合并到文本缓冲区。2.2 目录结构把中文修正从控件主类里拆出来如果所有修正都塞进控件主类后面加一个“行号栏”或“折叠标记”就会非常难改。我一般会把工程拆成下面这种结构控件主类只做事件转发和状态协调ChineseEditor/ Controls/ EditorControl.cs # 控件入口处理键盘、鼠标、滚动 EditorControl.Paint.cs # 只负责可见行绘制 Core/ TextBuffer.cs # 文本、行索引、样式范围 TextMetrics.cs # 字符宽度测量与缓存 LineLayout.cs # 行布局、前缀宽度、折行片段 LayoutCache.cs # 按行缓存布局文本变更时失效 Rendering/ SelectionPainter.cs # 选区高亮 StylePainter.cs # 语法高亮样式 CaretPainter.cs # 光标绘制 Demo/ MainForm.cs # 最小可运行示例 Program.cs Tests/ MetricsTests.cs # 宽度与命中测试 LayoutTests.cs # 折行与坐标换算这个结构不是摆设。TextMetrics 负责“一个码点占多少像素”LineLayout 负责“一行文本怎么切成前缀宽度数组”LayoutCache 负责“文本没变就不要重复测量”。EditorControl.Paint.cs 只做一件事拿到可见行的 LineLayout按滚动偏移画出来。这样中文修正逻辑可以单独写测试不用每次都启动窗体点鼠标。2.3 最小可运行 Demo 的启动路径拿到源码后先跑 Demo不要一上来就替换自己项目里的控件。旧式 .NET Framework 工程通常用 msbuild 打开解决方案新式 .NET SDK 工程可以直接在 Demo 目录执行# 在 Demo 目录下还原依赖并启动 dotnet restore dotnet run --project Demo/ChineseEditor.Demo.csproj如果是 .NET Framework 4.x 工程命令换成对应的 msbuild 编译再用 Visual Studio 启动。启动后重点看三件事中文注释是否完整显示、鼠标点击汉字中间光标是否落在正确字符边界、拖动滚动条后最后一行是否还能完整选中。Demo 里一般会预置一段中英混排文本包含全角标点、Tab、长行和空行用来暴露测量和折行问题。最小入口代码通常是这样// Program.cs只负责启动窗体不掺业务逻辑 static class Program { [STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); Application.Run(new MainForm()); } }SetCompatibleTextRenderingDefault(false)会让控件默认走 TextRenderer 路径和后面测量层保持一致。MainForm 里初始化编辑器时字体、字号、Tab 宽度、是否自动换行都要显式设置不要依赖系统默认值。参数上先固定Font new Font(Microsoft YaHei UI, 10.5f)或等宽字体TabSize 4WordWrap false先验证单行坐标再打开自动换行验证折行坐标。提示第一次跑 Demo 时先把 DPI 缩放调到 100% 和 150% 各看一遍很多中文错位不是代码错而是测量时没乘缩放比。3. 中文显示修正从字符宽度测量到绘制缓存的完整链路3.1 字符宽度测量为什么不能用 Length 乘字号中文显示修正的第一块基石是字符宽度。英文等宽字体里一个字符大约同宽text.Length * charWidth还能糊弄中文、全角标点、Tab、代理对混进来以后这个公式立刻失效。一个汉字在常见字体下接近两个英文半角字符宽但并不是精确两倍不同字体、不同字号、不同 DPI 下还会有零头。修正版 V2 把测量收敛到一个服务里按码点缓存宽度代理对按两个 char 处理组合字符不能拆开算。public sealed class TextMetrics { private readonly Font _font; private readonly float _dpiScale; private readonly Dictionaryint, float _widthCache new(); public TextMetrics(Font font, float dpiScale) { _font font; _dpiScale dpiScale; } // 按 Unicode 码点测量代理对不会被拆成两个半字符 public float MeasureCodePoint(int codePoint) { if (_widthCache.TryGetValue(codePoint, out var cached)) return cached; string text char.ConvertFromUtf32(codePoint); var size TextRenderer.MeasureText( text, _font, new Size(int.MaxValue, int.MaxValue), TextFormatFlags.NoPadding | TextFormatFlags.SingleLine); float width size.Width * _dpiScale; _widthCache[codePoint] width; return width; } // 测量一段文本调用方传入起始索引和长度 public float MeasureRange(string text, int start, int length) { float total 0; int end start length; for (int i start; i end;) { int cp char.ConvertToUtf32(text, i); int step char.IsSurrogatePair(text, i) ? 2 : 1; total MeasureCodePoint(cp); i step; } return total; } }_widthCache用码点做 key避免同一个汉字反复测量_dpiScale是当前控件 DPI 除以 96测量结果要乘上去TextFormatFlags.NoPadding去掉默认内边距否则每个字符会多出几个像素。MeasureRange里用char.ConvertToUtf32和IsSurrogatePair处理 emoji 或生僻字避免把代理对拆开后测出两个错误宽度。这里有一个容易忽略的参数字体回退。中文、英文、符号可能落在不同字体上TextRenderer 会做字体回退但回退后的宽度要以实际绘制结果为准。常见做法是测量和绘制都用同一个 Font 对象不要在绘制阶段临时 new Font否则缓存全废。3.2 行级布局缓存把测量结果挂到行对象上逐字符测量只是原料真正绘制时不能每帧都从头算。修正版 V2 会给每一行生成一个 LineLayout里面保存行起始索引、长度、每个字符的宽度、前缀宽度数组和总宽度。前缀宽度数组是关键光标定位、鼠标命中、选区高亮都靠它做二分查找。public sealed class LineLayout { public int Start; // 该行在全文中的起始字符索引 public int Length; // 该行字符数包含换行符前的可见字符 public float[] CharWidths; // 每个码点的宽度 public float[] PrefixWidths; // 前缀和长度比 CharWidths 多 1 public float TotalWidth; // 整行像素宽度 public ListWrapSegment Wraps; // 自动换行时的折行片段 } public sealed class WrapSegment { public int StartOffset; // 相对行首的字符偏移 public int Length; // 片段字符数 public float Width; // 片段像素宽度 }PrefixWidths[0] 0PrefixWidths[i 1] PrefixWidths[i] CharWidths[i]这样第 i 个字符的左边界就是PrefixWidths[i]右边界是PrefixWidths[i 1]。一行文本没变时直接复用这个对象文本增删改时只失效受影响的行不要整篇重建。自动换行打开后Wraps 里记录每一段折行的起始偏移和宽度绘制和命中的时候先定位到行再定位到折行片段。布局缓存还要处理 Tab。Tab 不是固定宽度字符它的宽度取决于当前列位置。常见做法是在构建 CharWidths 时按当前像素位置计算到下一个 TabStop 的距离而不是给 Tab 一个固定值。参数上TabSize 4表示每 4 个半角字符宽度一个制表位中文列宽要按实际像素算不能按字符数算。3.3 绘制阶段分段绘制与样式范围合并绘制阶段最容易犯的错是“一行文本一次 DrawString”。语法高亮需要不同颜色选区需要背景块一次画完整行就没法叠加样式。修正版 V2 通常按可见行循环再按样式区间和选区区间切段绘制。每一段都从 LineLayout 里取起始像素和宽度保证画出来的位置和测量结果一致。protected override void OnPaint(PaintEventArgs e) { var g e.Graphics; g.TextRenderingHint System.Drawing.Text.TextRenderingHint.ClearTypeGridFit; int firstLine GetFirstVisibleLine(); int lastLine GetLastVisibleLine(); for (int line firstLine; line lastLine; line) { var layout _layoutCache.GetOrCreate(line); float x Padding.Left - HorizontalScroll.Value; float y Padding.Top line * LineHeight - VerticalScroll.Value; // 先画选区背景再画文字避免文字被背景盖住 SelectionPainter.Draw(g, layout, x, y, SelectionStart, SelectionEnd); StylePainter.Draw(g, layout, x, y, _styleRanges); } }GetFirstVisibleLine和GetLastVisibleLine根据滚动偏移算可见范围避免整篇重绘x里减掉水平滚动值y里减掉垂直滚动值保证滚动后坐标仍然对得上先画选区再画文字是因为选区是背景块。StylePainter 内部会把样式范围按行拆分再按 LineLayout 的前缀宽度切成绘制段每段调用一次 TextRenderer.DrawText 或 Graphics.DrawString颜色和字体从样式表里取。参数说明LineHeight不能简单等于字体高度中文行高通常要比英文字体高一点否则上下行会挤Padding.Left和Padding.Top要参与所有坐标换算不能只用于绘制HorizontalScroll.Value和VerticalScroll.Value使用控件滚动条的实际值不要自己再维护一套偏移量。注意测量用 TextRenderer绘制却用 Graphics.DrawString中文宽度可能差一两个像素行数一多就会累积成明显错位。测量和绘制必须走同一套 API。4. 光标与选区像素坐标和字符索引怎么对齐4.1 CharIndexToPoint从逻辑索引找像素位置光标位置出问题本质是“字符索引转像素坐标”和“像素坐标转字符索引”没有用同一套布局数据。修正版 V2 的 CharIndexToPoint 先根据全局字符索引找到行再在行内用前缀宽度数组取左边界。注意全局索引可能落在换行符上也可能落在行尾边界要单独处理。public Point CharIndexToPoint(int index) { index Math.Clamp(index, 0, TextLength); int line GetLineFromCharIndex(index); var layout _layoutCache.GetOrCreate(line); int offset index - layout.Start; offset Math.Clamp(offset, 0, layout.Length); float x Padding.Left layout.PrefixWidths[offset] - HorizontalScroll.Value; float y Padding.Top line * LineHeight - VerticalScroll.Value; return new Point((int)Math.Round(x), (int)Math.Round(y)); }GetLineFromCharIndex用行起始索引数组做二分不要逐行遍历offset夹在 0 到layout.Length之间防止越界PrefixWidths[offset]直接给出该字符左边界行尾就是整行宽度。自动换行打开后还要先判断 index 落在哪个 WrapSegment再把片段内的偏移加上片段起始 x。这个方法返回的是光标左上角绘制光标时高度用 LineHeight不要用字体高度。4.2 PointToCharIndex鼠标点击命中字符鼠标点击是反方向换算先根据 Y 坐标找行再根据 X 坐标在行内前缀宽度数组里二分。这里最容易翻车的是滚动偏移和折行。Y 坐标要先加回垂直滚动值再减 Padding.TopX 坐标要先加回水平滚动值再减 Padding.Left。二分找到的是“最后一个小于等于点击 x 的前缀宽度”对应字符左边界。public int PointToCharIndex(Point p) { int line (p.Y VerticalScroll.Value - Padding.Top) / LineHeight; line Math.Clamp(line, 0, LineCount - 1); var layout _layoutCache.GetOrCreate(line); float localX p.X HorizontalScroll.Value - Padding.Left; int idx Array.BinarySearch(layout.PrefixWidths, localX); if (idx 0) idx ~idx - 1; idx Math.Clamp(idx, 0, layout.Length); return layout.Start idx; }Array.BinarySearch找不到精确值时返回按位取反的插入位置~idx - 1得到前一个边界夹在 0 到行长度之间保证点在行首左侧或行尾右侧也能落到合法索引。自动换行时不能直接按LineHeight算全局行要先按折行片段算虚拟行号再把片段偏移映射回逻辑行。这个细节不处理打开 WordWrap 后点击第二段折行就会跳到第一段。4.3 选区高亮与自动换行下的坐标换算选区高亮要把全局 SelectionStart 和 SelectionEnd 拆到每一行、每一个折行片段。先判断选区是否和当前行相交再取行内偏移最后用前缀宽度算左右边界。选区方向不重要重要的是先归一化成 Start 小于 End绘制时再按行处理。private void DrawSelection(Graphics g, LineLayout layout, float x, float y) { int selStart Math.Min(SelectionStart, SelectionEnd); int selEnd Math.Max(SelectionStart, SelectionEnd); int lineEnd layout.Start layout.Length; if (selEnd layout.Start || selStart lineEnd) return; int from Math.Max(selStart, layout.Start) - layout.Start; int to Math.Min(selEnd, lineEnd) - layout.Start; float sx x layout.PrefixWidths[from]; float ex x layout.PrefixWidths[to]; g.FillRectangle(SelectionBrush, sx, y, ex - sx, LineHeight); }from和to是行内偏移PrefixWidths给出像素边界FillRectangle的宽度是右边界减左边界不要少算一个字符宽度。自动换行时同一逻辑行会画多个矩形每个 WrapSegment 单独算 from/to不能把整行宽度一次画完否则高亮会跨到空白区。参数上SelectionBrush建议用半透明色避免盖住语法高亮颜色。5. 避坑与排查中文错位、滚动漂移、样式串色的 5 个现场5.1 现象光标总在汉字左半边点右边也跳回左边现象很具体鼠标点在“中文”两个字的中间偏右光标却回到“中”字左边按右方向键一次光标一次跳过一个汉字但视觉上像跳了半个字。原因通常不是光标绘制错而是命中测试拿字符数乘固定宽度或者测量时用了Graphics.MeasureString、绘制时用了TextRenderer两边宽度不一致。解决方法是把命中测试改成基于 LineLayout 的前缀宽度二分测量和绘制统一走 TextRenderer并且确保字体对象是同一个实例。排查时可以在鼠标点击处打印局部变量当前行号、行内偏移、localX、二分得到的idx、PrefixWidths[idx]和PrefixWidths[idx 1]。如果localX落在两个前缀宽度之间但 idx 明显不对先看行起始索引是否包含换行符再看 DPI 缩放有没有重复乘。5.2 现象滚动条越滚越偏最后一行选不中现象是拖动垂直滚动条到底最后一行只显示一半或者鼠标点到下半屏时行号算错。原因常见于三处滚动偏移没有参与 Y 坐标换算行高用字体高度而不是布局行高自动换行后虚拟行号和逻辑行号混用。解决方法是所有 Y 坐标统一走Padding.Top line * LineHeight - VerticalScroll.Value行高从布局缓存取WordWrap 打开时先算虚拟行再映射逻辑行。排查时把VerticalScroll.Value、LineHeight、Padding.Top和点击 Y 一起打印看(p.Y scroll - padding) / lineHeight是否落在预期行。如果滚动值每次增加 1 但行高是 19.5整数除法会累积误差行高要用 float 计算最后再取整绘制。5.3 现象选中高亮串到下一行样式颜色也跟着串现象是拖选中文注释高亮块超出当前行或者语法高亮颜色串到相邻行。原因通常是样式范围按字节索引或 UTF-16 索引混用选区边界没有按行裁剪。代理对占两个 char如果样式范围按码点算、文本模型按 char 算边界就会错开。解决方法是全文统一用 UTF-16 字符索引代理对在测量时按码点处理但索引仍然按 char 记录绘制选区时按行和折行片段裁剪。排查时打印SelectionStart、SelectionEnd、当前行Start、Length确认Math.Max和Math.Min裁剪后的 from/to 没有负数或超过行长度。样式串色还要检查 StyleRange 是否在文本修改后失效重建旧范围挂在新文本上一定会串。5.4 现象换到 150% DPI 后全乱字体变大光标没变现象是系统缩放调到 150%文字变大了光标和选区还是按 100% 算点击位置整体偏移。原因是测量时没有乘 DeviceDpi / 96或者控件只处理了字体缩放没处理布局缩放。解决方法是 TextMetrics 初始化时传入当前 DPI 缩放比所有测量结果乘缩放比控件收到 DpiChanged 后清空宽度缓存和布局缓存重新测量。排查时在 100% 和 150% 下各点同一个汉字位置比较localX和PrefixWidths。如果 150% 下宽度大约变成 1.5 倍但前缀宽度没变就是缓存没失效。缓存 key 里也可以把 DPI 带进去避免跨 DPI 复用旧值。5.5 现象输入法组合文本闪烁上屏后光标跳一下现象是拼音输入过程中候选文字已经显示在编辑区但光标位置不对上屏后文本插入位置跳一下。原因是组合文本直接写进了正式文本缓冲区组合期间字符索引和样式范围都被临时改动。解决方法是维护 composition 区间组合文本只在绘制层叠加显示不进入 TextBuffer上屏提交时一次性插入正式文本并刷新布局。排查时在 IME 的 StartComposition 和 EndComposition 事件里打日志看组合期间 TextLength 是否变化。如果变化了说明组合文本提前进了缓冲区。常见做法是组合期间禁止语法高亮重新计算提交后再统一刷新受影响行。提示这五类问题不要同时改先固定 DPI 和字体再关掉 WordWrap 验证单行坐标最后逐项打开自动换行、语法高亮和输入法。6. 二次开发把修正逻辑抽成可复用服务并做回归验证真正落到自己项目里不建议直接改控件主类里的绘制方法。更稳的做法是把测量和命中抽成接口控件只依赖接口后面换字体、换 DPI、加行号栏都不用动核心逻辑。public interface ITextMetrics { float MeasureRange(string text, int start, int length); int HitTest(string text, int lineStart, int lineLength, float localX); }MeasureRange负责像素宽度HitTest负责点击命中两个方法都只依赖文本和布局不依赖具体控件。实现类里保留宽度缓存控件在字体、DPI、文本变化时调用Invalidate。验证时不要只靠肉眼看给几个固定用例中英混排行、全角标点行、Tab 行、代理对行、空行分别断言光标索引转坐标再转回来等于原索引。验证项方法通过标准中文光标命中点击每个汉字左右边界返回索引与字符边界一致选区高亮拖选三行中英混排高亮块不跨行、不串色滚动坐标滚到底部再点击最后一行行号与点击位置一致DPI 缩放100% 与 150% 各跑一遍光标不偏、行高不重叠输入法提交拼音上屏后继续输入插入位置不跳、不丢字从那以后我每次接编辑器控件都强制先跑一遍中文坐标回归用例再谈语法高亮和折叠。别等用户反馈“光标怎么在汉字中间”才回头查测量那时候样式、滚动、输入法已经缠在一起了。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑