简介:FastColoredTextBox 中文修正版 V2 面向使用 WinForms 开发代码编辑器、配置编辑器或语法高亮控件的 C# 开发者,重点修复原版中文双字节显示异常、光标定位错位以及多 Style 叠加时的样式错乱问题。压缩包共 463 个文件,约 17.01MB,包含 125 个 cs 源码、61 个 resx 资源、40 个 vb 示例及 png、dll、xml、sln 等配套文件,覆盖控件源码、示例工程、资源与构建配置,便于直接编译调试和对照修改。已有 1763 人学习下载。读者可获得可运行的修正版源码、完整示例与工程结构,快速定位中文渲染、光标测量和多高亮样式冲突的处理方式,减少在富文本代码控件上重复踩坑,适合需要稳定中文编辑体验的桌面项目集成参考。
1. 粘贴中文就重影、光标乱跳:这套控件的坑从哪来
一段中文粘进 FastColoredTextBox,屏幕上立刻出现三种毛病:字和字叠在一起像重影,光标点下去停在错的位置,选中一行高亮框只盖住文字的前半截。这三种都不抛异常、不打日志,只是眼睛看着不对,所以排查起来格外折腾,很容易被误判成字体问题或者显卡渲染问题,改半天 Font 属性一点用没有。这份中文修正版 V2 针对的正是这三个点:中文显示、光标位置、样式错位。它适合正在用这套控件做配置编辑器、脚本编辑框、日志查看器的开发者,尤其是界面里必须出现中文、又不想为了中文换掉整个编辑控件的场景。下面按故障类型拆开讲,先说清为什么会这样,再落到具体改哪几行。
2. 中文显示成方块、字叠字:字体回退与双宽字符测量
2.1 先把三种「看不见中文」分开,别混成一个问题
很多人一看到中文显示不正常就去改字体,改完发现方块变成了乱码方块,问题还在。实际上「中文显示异常」至少是三种不同的故障,触发条件和修法完全不同。第一种是方块:字体里没有对应字形,系统画一个空心方框顶上,这时字符宽度是对的,只是没有形。第二种是空白:字形有,但前景色和背景色撞了,或者被背景绘制盖住,看起来像什么都没画。第三种是叠字/重影:字形正常,但每个字符的落点算错了,后一个字符压在前一个字符身上。这三种里只有第三种跟控件的中文修正有关,前两种是字体和配色问题。
区分方法很简单:把鼠标拖过去选中那一行。如果选区高亮范围正常、只是里面没字形,那是字体问题;如果选区高亮本身就短了一截、跟文字对不上,那就是落点计算错了,属于控件层面的宽度模型问题。我一般先用这个动作做一次二分,能省掉一半的无效尝试。
第三种故障的根因在控件的绘制公式上。FastColoredTextBox 画字符时按x = LeftPadding + (i - lineStart) * CharWidth算落点,其中CharWidth是启动时测出来的一个常量,代表「一个字符多宽」。这个假设在英文等宽字体下完全成立,因为每个 ASCII 字符确实都等于CharWidth。但一个汉字的实际宽度大约是 ASCII 字符的两倍,公式里却仍然只前进一个CharWidth,于是第二个汉字直接画到了第一个汉字身上,视觉上就是糊成一团的重影。行内汉字越多,重叠越密集,一整行中文看上去就像被压扁了。
2.2 字体回退链:设了 Consolas 中文就消失是怎么回事
代码编辑器默认喜欢用 Consolas、Courier New 这类等宽字体,它们对英文的显示效果确实好,但这些字体本身不含 CJK 字形。Windows 有一套字体链接(font linking)机制,理论上缺字形时会自动回退到系统默认的中文字体,但这套机制在 GDI 和 GDI+ 两条绘制路径上的表现并不一致:用TextRenderer.DrawText(GDI)走链接,用Graphics.DrawString(GDI+)可能就不走,结果就是同一个控件里一部分文字有中文、一部分是方块。
常见的做法是显式指定一个同时覆盖英文和中文的字体,而不是依赖回退。微软雅黑 UI 在等宽性上不如 Consolas,但对中英文混排的兼容性最好;如果一定要保留等宽的英文观感,就得自己在测量阶段处理回退,不能指望系统帮你兜底。
// 显式指定字体,避免依赖系统 font linking // 关键点:字体一旦确定,所有宽度都必须基于这个字体测量,不能中途换 var editorFont = new Font("Microsoft YaHei UI", 10f, FontStyle.Regular, GraphicsUnit.Point); // 如果确实想用 Consolas 的英文观感,就只在英文字符上用它, // 中文字符统一走回退字体,两者宽度都要缓存 private Font GetFontFor(char c) { return IsWideChar(c) ? _cjkFont : _asciiFont; }这里有两个参数值得说。字号用10f而不是9,是因为整数磅值在 125%、150% 缩放下换算成像素时取整误差更明显,用浮点能少一层损失。GraphicsUnit.Point必须显式写出来,虽然它也是默认值,但一旦有人从别处复制了一段用GraphicsUnit.Pixel的代码混进来,同一份配置在不同 DPI 下测出来的宽度会差 30% 以上,这种问题排查起来极其费时。
提示:换字体之后一定要把所有跟宽度、行高相关的缓存全部失效重算,否则你会看到一个更诡异的现象——字体明明换了,行高还是旧的,最后一行被裁掉半截。
2.3 用 TextRenderer 逐字符测宽,把 CharWidth 从常量变成查表
知道了根因,修法就有两条路。一条是「给中文乘 2」,另一条是「逐字符测量并缓存」。前者看起来省事,实际上只能对付纯汉字,遇到全角逗号、全角括号、日文假名、韩文谚文、emoji 就会翻车,因为这些字符的宽度各不相同,有的接近一个 ASCII,有的接近两个,乘 2 反而制造新的错位。后者多花一点内存,但一劳永逸。
另一个容易被忽略的细节是测量和绘制必须用同一套 API。原先的CharWidth是用Graphics.MeasureString测的(GDI+),而实际绘制走的是TextRenderer.DrawText(GDI),两条路径对同一个字符串给出的宽度能差出每字符 1 到 2 像素。单看一个字符无所谓,一行 60 个字符累积下来就是 60 到 120 像素的偏差,这正好解释了「为什么行尾的字偏得最厉害」。
// 双宽字符判定:覆盖 CJK 汉字、假名、谚文、全角标点等东亚宽字符区段 internal static bool IsWideChar(char c) { return (c >= 0x1100 && c <= 0x115F) // 谚文字母 || (c >= 0x2E80 && c <= 0x303E) // 部首、康熙部首、CJK 标点 || (c >= 0x3041 && c <= 0x33FF) // 假名、注音、CJK 兼容 || (c >= 0x3400 && c <= 0x4DBF) // 扩展 A 区汉字 || (c >= 0x4E00 && c <= 0x9FFF) // 基本汉字区 || (c >= 0xA000 && c <= 0xA4CF) // 彝文 || (c >= 0xAC00 && c <= 0xD7A3) // 谚文音节 || (c >= 0xF900 && c <= 0xFAFF) // CJK 兼容汉字 || (c >= 0xFE30 && c <= 0xFE4F) // CJK 兼容形式 || (c >= 0xFF00 && c <= 0xFF60) // 全角 ASCII || (c >= 0xFFE0 && c <= 0xFFE6); // 全角符号 } // 按字符缓存实际宽度,命中缓存直接返回,避免每帧重复测量 private readonly Dictionary<char, int> _widthCache = new Dictionary<char, int>(); internal int GetCharWidth(char c) { if (_widthCache.TryGetValue(c, out var w)) return w; // 用与绘制完全相同的 TextRenderer 测量,保证测出来的就是画出来的 w = TextRenderer.MeasureText( c.ToString(), GetFontFor(c), new Size(int.MaxValue, LineHeight), TextFormatFlags.NoPadding | TextFormatFlags.NoPrefix).Width; _widthCache[c] = w; return w; }TextFormatFlags.NoPadding是最关键的一个参数。默认情况下TextRenderer.MeasureText会在文本四周加内边距,每个字符多出 1 到 3 像素,几十个字符累积起来就是几十像素的偏移,而且这个偏移跟字符内容有关,表现得极不规律。NoPrefix则用来关掉「& 被当成助记符前缀吃掉」的行为,代码编辑器里&&、& 0xFF这类写法非常常见,不加这个标志,测量结果会莫名其妙地少一个字符宽度。
缓存这一层也值得多说一句。Dictionary<char, int>的 key 是单个char,对于 BMP 内的字符够用;但 emoji 和部分生僻汉字在 UTF-16 里是代理对(两个char组成一个字符),单字符查表会把它们拆成两半,测出两个半宽加起来正好等于一个全宽,视觉上勉强能看,但光标定位会落在这两个char中间,删除时只删一半,直接产生乱码字符。修正版里对代理对做了合并处理,先判断char.IsHighSurrogate再取下一个char一起测。
3. 光标总偏右几格、选区框不住字:PointToPosition 的等宽假设
3.1 偏移量随中文个数线性增长,这是个可以自己验证的规律
鼠标点在某一行中间,光标没有出现在点击位置,而是跑到了这一行的后半段甚至行尾。这个现象有一个非常稳定的特征:偏差大小与点击位置之前的中文个数成正比。英文行点击基本准,一个汉字的行偏一格左右,十个汉字的行偏十格左右。你可以做一次简单验证:打开一个中英混排文件,在纯英文行点行首点行尾各一次,再在纯中文行做同样的动作,记录光标实际落点,偏差曲线几乎是直线。
这个规律说明问题不在「字符宽度测得准不准」,而在「定位公式用不用得上这个宽度」。PointToPosition的原始实现大致是拿鼠标 x 坐标减去左边距,再除以CharWidth得到列号,本质上还是那个等宽假设。既然一个汉字占两个CharWidth,那么点击第 3 个汉字时,公式算出的列号大约是 6,光标自然就跳到右边去了。
顺带说一个反直觉的地方:这个 bug 在「输入」时不明显,在「点击」和「拖选」时特别明显。因为键盘输入主要靠CaretIndex顺序推进,不太依赖坐标反算;而鼠标操作必须靠坐标反算,误差就全暴露出来了。所以有人反馈「打字没问题,就是点不准」,这不是玄学,是两条代码路径的差异。
3.2 PointToPosition 改成逐字符累加,别再乘 CharWidth
修法是把「乘法」换成「累加」。既然每个字符的真实宽度已经能查表拿到,那就从行首开始一个个加过去,加到哪一格超过鼠标位置就返回那一格。这样无论字符是半宽、全宽还是三宽(部分 emoji 场景),定位都天然正确,不需要为每种宽度写特例。
// 鼠标坐标 -> 字符索引:逐字符累加真实宽度,替代 (x - LeftPadding) / CharWidth public int PointToCharIndex(Point point) { int line = FindLineByY(point.Y); // 先按行高定位逻辑行 if (line < 0) return -1; int start = GetLineFirstCharIndex(line); // 该行首字符在全文中的索引 int lineLen = GetLineLength(line); int x = point.X - LeftPadding; // 换算到文本区内的相对坐标 int acc = 0; for (int i = 0; i < lineLen; i++) { int w = GetCharWidth(this[start + i]); // 落在字符前半段算命中本字符,落在后半段算下一个字符 if (x < acc + w / 2) return start + i; acc += w; } return start + lineLen; // 超出本行末尾,落在行尾 }那个w / 2是整段代码里最容易被改错的地方。如果写成x < acc + w,点击一个汉字的后半部分会跳到下一个字符,用户会觉得「光标插不进去」,尤其是想在一个汉字后面追加内容时怎么点都点不中。如果写成x < acc,那就是永远只能点在字符左边缘,输入体验更差。用半格判定,行为跟 Windows 自带的记事本、VS Code 一致,用户不会觉得别扭。
FindLineByY这一步也有个坑。行高必须用当前字体重新算,不能沿用启动时缓存的LineHeight。中文行和英文行如果字体不同,行高可能不一样;即使字体相同,换行符类型不同(\r\n和单独的\n)也会影响行数统计。修正版里把行高和行首索引都做了按需重算,代价是每次布局变化要多走一遍文本,但换来的稳定性值得。
3.3 选区与语法高亮:按显示宽度重算,而不是按字符数
光标定位修好了,选区高亮还会错。原因在于高亮矩形的宽度是另一段代码算的,很多实现里写的是(end - start) * CharWidth,又是一个乘法。结果就是:文字位置对了,包住文字的高亮框还是短的,看起来像「选中了一半」。
最省事也最不容易再出错的写法是复用定位函数,不要另起一套计算逻辑。既然PositionToPoint已经能把字符索引转成准确坐标,那就直接用它算两个端点再相减,绘制和命中共用同一套宽度模型,天然不会脱节。
// 选区高亮矩形:直接复用字符索引 -> 坐标的换算,避免两套宽度逻辑打架 private Rectangle GetSelectionRect(int startChar, int endChar) { Point p1 = PositionToPoint(startChar); // 选区起点(客户区坐标) Point p2 = PositionToPoint(endChar); // 选区终点 int lineHeight = GetLineHeight(p1.Y); // 按起点所在行取行高 // 跨行选区要单独处理,这里只演示同一行内的情形 return new Rectangle(p1.X, p1.Y, Math.Max(1, p2.X - p1.X), lineHeight); }Math.Max(1, ...)这一层保护看着多余,实际很有用:空选区或者零宽字符(比如零宽空格 U+200B,从网页复制代码时经常带进来)会导致宽度算成 0,矩形宽度为 0 时某些 GDI+ 绘制分支会直接不画,表现出来就是「有时候选中了但看不见高亮」,又是一个看起来像玄学的问题。
语法高亮走的是另一条路,但根因相同。高亮器给每个 token 标一个样式索引,绘制时按索引取颜色和字体。如果某个样式用了FontStyle.Bold,这个 token 的实际字符宽度就会比常规字重宽一点点,而宽度缓存里只按字符和字体名存了一份,没有区分字重,于是加粗的关键字后面的中文就会整体右移几像素。这个问题在短行上看不出来,长行上非常明显,而且是「只有含关键字的行才偏」这种规律性极强的表现。
4. 输入法候选框飘到窗口外:WM_IME_COMPOSITION 没接住
4.1 组合串提交之前,CaretIndex 指向的位置是「假」的
用中文输入法打字,拼音还没上屏时,那串字母叫组合串(composition string)。组合串在提交之前并不属于控件的文本内容,它是由输入法自己在窗口上画出来的。这就带来一个麻烦:此时CaretIndex指向的是组合串的起点,而用户眼睛看到的光标已经跟着待选字符走到后面去了。如果你在这个时刻用PositionToPoint(CaretIndex)去定位候选框,框会钉在组合串开头,而不是用户正在看的位置。
这个偏差在纯英文输入时完全不存在,所以很多人根本没意识到有这回事,直到有用户反馈「打拼音时候选框跑到左边去了」才发现。更麻烦的是它跟输入法实现有关,微软拼音、搜狗、QQ 拼音的行为不完全一致,有的会在组合期间更新插入符位置,有的不会。
还有一个更隐蔽的坑:控件里通常有个imeAllowed之类的开关,本意是控制要不要处理 IME 消息。有人因为候选框位置乱,干脆把它设成false,位置确实不乱了——因为中文根本打不进去了。这种「用一个更大的故障掩盖小故障」的操作,在排错现场非常常见,值得单独警惕。
4.2 ImmSetCompositionWindow 的三个参数怎么填
正确做法是保留 IME 消息处理,并在组合开始时主动把候选框位置告诉输入法。这需要调用ImmGetContext拿到输入法上下文,填一个COMPOSITIONFORM结构,再ImmSetCompositionWindow设置回去。三个参数里,dwStyle决定用哪种定位方式,ptCurrentPos是定位点,第三个成员rcArea只在CFS_RECT或CFS_EXCLUDE模式下才用得上。
// 在 WndProc 里拦 IME 消息,把候选框钉到当前插入符位置 protected override void WndProc(ref Message m) { const int WM_IME_STARTCOMPOSITION = 0x010D; const int WM_IME_COMPOSITION = 0x010F; const int CFS_POINT = 0x0002; if (m.Msg == WM_IME_STARTCOMPOSITION || m.Msg == WM_IME_COMPOSITION) { // 组合串起点 + 已输入的组合字符宽度 = 视觉上真正的位置 int visualIndex = CaretIndex + _compositionCharCount; Point p = PositionToPoint(visualIndex); // 必须返回客户区坐标 IntPtr hIMC = ImmGetContext(Handle); if (hIMC != IntPtr.Zero) { var cf = new COMPOSITIONFORM { dwStyle = CFS_POINT, // 只钉一个点,不画输入法自带边框 ptCurrentPos = new POINT { X = p.X, Y = p.Y } }; ImmSetCompositionWindow(hIMC, ref cf); ImmReleaseContext(Handle, hIMC); // 必须释放,否则句柄泄漏 } } base.WndProc(ref m); }dwStyle用CFS_POINT而不是CFS_RECT,是因为CFS_RECT会让输入法在指定矩形内自绘一个带边框的候选区域,在深色主题下那个边框经常是白底黑框,直接糊在代码上,非常难看。用CFS_POINT只告诉它「从哪开始」,具体怎么画交给输入法自己按系统主题来,兼容性最好。
ImmReleaseContext这一行绝对不能省。每次组合开始都ImmGetContext一次而忘了释放,输入法上下文句柄会持续累积。短时间打字看不出来,连续输入几十分钟之后会出现候选框彻底不显示、切换输入法卡死、甚至整个窗口失去响应。这类问题复现条件模糊,用户往往描述成「用久了就卡」,很难联想到是句柄泄漏。
坐标必须是客户区坐标,不是屏幕坐标。这个错误特别容易发生在高 DPI 场景下:如果混用了PointToScreen,在 150% 缩放下候选框会偏到右下角,偏移量正好是缩放比,乍一看还以为是缩放算错了,实际上是坐标系用错了。
4.3 全角标点、emoji 与代理对:宽度计算的最后一公里
前面两节解决的是「大部分中文」的问题,剩下那一小撮字符会以各种奇怪的方式漏网。全角标点(,。!?;:)在 Unicode 里属于FF00到FF60区段,宽度是一个汉字的宽度,但很多宽度判定函数只写了「汉字区段」,把它们漏了,结果就是句子里的逗号之后所有字符都往左偏一格。这个偏差在长段落里看不出来,在只有一两个标点的短行上反而显眼。
emoji 更麻烦。大部分 emoji 在 UTF-16 里是两个char组成的代理对,某些组合 emoji(比如带肤色的手势、家庭组合)甚至是四到七个char加零宽连接符拼起来的。如果按单个char计宽度,一个 emoji 会被拆成两个半宽累加,总宽恰好等于全宽,看起来没问题;但光标可以落在这两个char中间,按退格只删掉一半,屏幕上立刻出现一个「�」,用户就会说「中文修正版有 bug」。
// 代理对与零宽字符的处理:一次吃掉一个完整码位,而不是一个 char private int GetCodePointWidth(int charIndex) { char c = this[charIndex]; // 高代理 + 低代理 = 一个完整码位,宽度按整码位算 if (char.IsHighSurrogate(c) && charIndex + 1 < TextLength && char.IsLowSurrogate(this[charIndex + 1])) { return GetCharWidth(c) + GetCharWidth(this[charIndex + 1]); } // 零宽字符不占位,但也不能返回负数,否则累加时会破坏单调性 if (c == '\u200B' || c == '\uFEFF') return 0; return GetCharWidth(c); }零宽字符返回 0 而不是跳过,是为了保证「索引到坐标」这个映射的单调性不被破坏。如果直接把零宽字符从累加里剔除,某些边界情况下会出现两个不同索引映射到同一个坐标,鼠标点击时前后光标来回抖。这类细节不影响绝大多数场景,但一旦触发,表现就是「光标在某个位置点不进去」,排查成本极高。
5. 避坑记录:改完宽度之后更乱的五种情况
5.1 字体与宽度:三条改完反而更花的记录
记录一:换字体后最后一行被裁掉一半。现象是换了微软雅黑之后,中文能显示了,但滚动到底部时最后一行只露出上半截。原因不在宽度,而在行高:LineHeight是启动时按Font.Height算出来缓存着的,换字体只触发了重绘,没有触发重算,可见行数还是按旧行高算的,导致最后一行被切。解决办法是重写OnFontChanged,在里面一次性清空宽度缓存、重算行高、重算可见行数,三件事必须绑在一起做。
protected override void OnFontChanged(EventArgs e) { base.OnFontChanged(e); _widthCache.Clear(); // 宽度缓存全部失效 RecalculateLineHeight(); // 行高重算 RecalculateVisibleLines();// 可见行数重算,否则滚动条范围会错 Invalidate(); // 三件事做完再统一重绘 }记录二:关键字一加粗,后半行整体右移。现象是某几行文字明显比别的行偏右,而且只有含关键字的行才有这个问题。原因是宽度缓存只按字符和字体名存,没区分字重,FontStyle.Bold的实际宽度比常规字重大一点点,绘制时按常规宽度画就偏了。解决有两条路:一是高亮样式干脆不用加粗,只改前景色;二是把缓存 key 改成(char, FontStyle)的组合,按字重分别缓存。前者更省事,后者更还原。
记录三:把系统缩放调到 150% 之后,中文越往后越偏。现象是同一段文字在 100% 缩放下正常,在 150% 下每行末尾偏十几像素。原因是CharWidth是 int,缩放后算出来是 8.4 像素,被截断成 8,一行 40 个字符就累积偏差 16 像素。解决办法是内部累加全程用float,只在最终绘制和命中判定时取整;同时在DpiChanged事件里清空所有缓存重新测量。开启 PerMonitorV2 之后这个事件才可靠触发,单显示器时代那套写法在双屏不同缩放的机器上会彻底失效。
5.2 光标与选区:只在特定操作之后才复现的两条记录
记录四:拖选时高亮块比文字短一截,但点击定位是准的。这个组合特别能说明问题:定位准说明PositionToPoint修好了,高亮短说明绘制选区的代码还在用老公式(end - start) * CharWidth。两套宽度逻辑并存,就是这类 bug 的典型形态。改法是让选区绘制直接调用PositionToPoint算两端再相减,全项目搜一遍* CharWidth把所有残留的乘法清干净,只留一处宽度来源。
// 排查用:把还在用乘法算宽度的地方找出来,一个个改成累加 // git grep -n "\* *CharWidth" -- "*.cs" var rect = GetSelectionRect(SelectionStart, SelectionEnd); // 改后:复用定位函数 // var width = (SelectionEnd - SelectionStart) * CharWidth; // 改前:等宽假设记录五:粘贴进来的文本看着正常,一按方向键光标就跳。现象很怪:刚粘进来的内容显示完全正常,光标也在对的位置,但只要按一次左右方向键,光标就跳到几个字符之外。原因是粘进来的文本混了单独出现的\r(老式换行符)和全角空格 U+3000,行首索引表按\n切分,遇到单独\r时行列对应关系就错位了。解决是粘贴入口统一做一次规范化:\r\n和\n统一成\r\n,单独\r替换掉,U+3000 不删但必须走宽字符测量路径。顺手把不可见字符做成可选的显示开关,下次再遇到类似问题一眼就能看出来。
注意:以上五个坑里有三个都是「改了一半」造成的,不是原始 bug。改这类控件最忌讳只修看得见的那一处,宽度模型一旦动,所有依赖它的地方都得跟着过一遍。
6. 三分钟定位:把「中文异常」拆成四个可验证的观察点
排这类问题不需要一上来就读完整份源码,先做四个动作,基本能把故障范围缩到一小块代码里。
观察点一:Ctrl+End 到文末,看光标停在哪。正常应该停在最后一个字符的右边缘。如果光标停在最后一个字的里面或者右边老远,说明是宽度模型的问题,跟绘制无关,直接去查GetCharWidth和PositionToPoint。如果光标位置对但字是方块,那是字体,去查字体回退链。
观察点二:分别在纯英文行和纯中文行点三次行尾,记录偏差格数。偏差跟中文个数成正比,就是等宽假设没破;偏差是个固定值,那多半是LeftPadding或者内边距没扣干净;偏差随行号增大而增大,那是高 DPI 下的取整累积。
观察点三:把系统缩放改到 150%,重启程序,重复观察点二。100% 下正常、150% 下偏,问题在取整和 DPI 事件处理;两个缩放比下都偏同样的像素数,问题在坐标系用错(客户区 vs 屏幕坐标)。
观察点四:切中文输入法打三个字但不提交,看候选框位置。候选框钉在组合串开头,是没处理WM_IME_COMPOSITION;候选框跑到窗口外,是坐标算成了屏幕坐标;候选框根本不出现,先查ImmReleaseContext有没有漏,再查imeAllowed有没有被人设成false。
| 观察到的现象 | 最可能的根因 | 先看哪里 |
|---|---|---|
| 字叠字、越往后越糊 | 落点用乘法而非累加 | PointToPosition及其调用方 |
| 中文变方块,选区范围正常 | 字体缺 CJK 字形 | 字体回退链 |
| 点击位置偏,打字正常 | 坐标反算依赖等宽假设 | PointToCharIndex |
| 高亮比文字短 | 选区宽度仍在用乘法 | 绘制选区那段 |
| 输入法候选框位置乱 | IME 消息没接或坐标错 | WndProc里的 IME 分支 |
这套顺序我用了很久,基本上四个动作做完就能确定该翻哪个文件。那份修正版 V2 把上面提到的宽度缓存、逐字符累加、选区复用定位、IME 组合串偏移这几处都改在源码里了,拿到之后直接替换原来的控件源文件、重新编译即可,调用方的new FastColoredTextBox()和各种属性设置都不用动,语法高亮器的配置也不用重写。
从那以后我每次接这类编辑器控件的活儿,都强制先把「宽度、定位、IME」这三个断点各跑一遍,再动手改代码,哪怕需求只是「让中文能显示」——因为这三处只要有一处没对齐,后面所有的修修补补都会变成拆东墙补西墙。希望帮到你。
本文还有配套的精品资源,点击获取