自己写的编辑器卡在哪:输入法、导出与每帧性能
用 Rust + egui 重写一个 MarkText 风格的 Markdown 编辑器,功能本身不难——难的是三件事:输入法要能用中文、导出要能看、大文件要能不卡。
这三件事有个共同点:写完不报错,用起来才发现不行。输入法不接就是打不出字,导出不处理就是一堆方块,每帧哈希就是滚动条抖。这篇把这三个方向上的具体坑记下来,顺带记两处桌面集成的连带问题。
一、先定「能用」的基线
动手改之前先明确验收标准,否则「看起来能用」会一直骗你:
- 界面:按 MarkText 的样子对齐(标题栏、标签栏、行距),而不是自己发挥;
- 交互:IME 要能在真实 fcitx5 下正常出候选、能提交、能取消;
- 导出:PDF/HTML 里的中文、表格、代码块、公式、Mermaid 都要能看;
- 性能:15MB 大文件滚动不卡;
- 构建:所有构建与测试只走 CI,不在本地跑(本地
cargo clean一次性删掉过 10.1 GiB)。
基线定下来之后,每个坑都能归到某一条上,不容易跑偏。
二、输入法要打通三层
中文输入在自绘 GUI 里失败,通常不是一层的问题,而是三层里任意一层没打通。我这次是四层都踩了一遍:
第一层:环境变量把 XIM 关了。 应用启动时 XMODIFIERS=@im=none,XIM 直接被禁用。按键能进窗口,但永远不进入输入法。
第二层:没告诉 winit 允许 IME。 自绘编辑器只上报了候选窗位置,却从没调用「允许 IME」的接口——结果 shi 被当成普通文本逐字插入。对照官方 TextEdit 实现补上允许调用即可。
第三层:输入法本身停在英文模式。 fcitx5 的 status=1 表示英文模式,此时字母不会组合。用 fcitx5-remote 确认返回 2(拼音)再测。
第四层:egui 的帧顺序。 这一层最容易被误判成产品 bug:
IME 事件是在绘制之后处理的,预编辑文本要到下一帧才画出来。
所以端到端测试如果按「同一帧就能断言到预编辑」写,会一直失败。正确做法是改成按下一帧语义断言,而不是去改产品代码迁就测试。
另外,在 X11 下做按键注入时 XTEST 会被合成器拦截(XWayland 尤其明显),注入的键根本不生效。要测真实输入得改用 uinput 虚拟键盘。
三、PDF 导出:字体、断行与绘制状态
导出的问题是一串一串出现的,每修一个就露出下一个。
中文全是方块。 导出字体只嵌了 Inter 和 JetBrains Mono,没有中文字体 → 嵌入思源黑体。
中文长段落不换行、溢出页面。 缺少 CJK 断行逻辑——西文按空格断,中文要按字符断,还得处理行首标点禁则。
全角标点导致行宽溢出、断行错位。 U+FF0C(,)这类全角标点不在 CJK 统一表意文字区间里,宽度被按半角算,于是每行都算短了。
表格正文行全是深色底。 这是绘图库的填充状态泄漏到描边矩形——每个绘制函数都必须显式重置 fill/stroke,不能假设状态干净。
行内公式撑破行高、块级公式压住上一行。 $$...$$ 被 Markdown 解析器包进了 Paragraph,于是按行内公式绘制、行高也没扩容。修法是行高按公式高度扩展,并把 $$...$$ 改成独立居中块。
链接变成黑色。 链接色被父级颜色覆盖 → 链接色优先级要高于父级,并加下划线。
任务列表的勾选框缺字。 思源黑体里没有 ☑/☐,换成它确实包含的 √/□。
这几条连起来看,规律很清楚:导出不是「把 Markdown 画出来」,而是一次小型排版引擎的实现——字体覆盖、断行规则、盒模型状态、公式尺寸,缺一样就出一样的问题。
四、每帧全文哈希:大文件为什么卡
大文件卡顿的根因不是渲染,是每个渲染路径都在每帧做全文工作:
- Raw 模式每帧调
prepare_undo_snapshot_hashed(),对全文做 blake3; - Rendered 模式每帧走
get_or_parse,同样对全文哈希。
15MB 的文件,每帧算一次全文哈希,滚动当然抖。
修法是把所有缓存与快照改成按内容版本号(content_version)失效:滚动、移动光标这类不改内容的操作完全不触发哈希,只有内容真的变了才递增版本号、重建缓存。
顺带做了几处同类整改:
- 外部重载只赋值
content是不够的——编码、BOM、原始哈希、磁盘锚点都得一起更新,否则等长的外部修改会被旧缓冲覆盖、非 UTF-8 文件重载会损坏。抽成replace_from_disk单一入口。 - AST 缓存加 64MB 上限,否则越开越占。
- 代码执行加 2MB 输出上限,并杀进程树、临时文件 0600。
五、启动恢复的两条路径
启动时「上次的标签页没了」「已经有标签却被清空」,根因是两个恢复函数先后执行:
1 | restore_session_tabs() // 不先清空 |
后者一执行,前者的结果就被抹掉;而且恢复失败时也照清不误。
修法是只保留一条恢复路径:先构建恢复计划,再原子替换;失败就保留现有标签,什么都不做。
这类问题的共性:同一个状态有两条写入路径时,顺序就是 bug 的温床。要么合并成一条,要么明确谁先谁后。
六、打包与 CI 的四个坑
Cargo.lock 没提交,CI 直接失败。 功能提交只带了 Cargo.toml 和源码,CI 用 cargo check --locked,报 the lock file needs to be updated but --locked was passed。锁文件是仓库产物,必须一起提交;提交前本地先跑一次 --locked 验证自洽。
NSIS 的相对路径基准是「脚本所在目录」。 不是仓库根。File/OutFile 写成 target/release/mdoxide.exe 会找不到,要写成 ..\..\target\release\mdoxide.exe。
Windows runner 没有 makensis。 release workflow 里要先装(Chocolatey),否则 Windows 包永远出不来。
Dependabot 会把「工具链版本的 ref」当成 action 版本升级。 dtolnay/rust-toolchain@1.92.0 指的是 Rust 版本(由 rust-toolchain.toml 固定),Dependabot 却把它当 action 版本提到 1.120.0,必然失败 → 加 ignore 规则。
另外一条经验:版本号从 tag 里取时别混入 v 前缀;本地残留的旧 tag 要先删再重新指向当前发布提交,否则 release workflow 一直是「没有新 tag」。
七、附:桌面集成的另外两处
同一个月的两个桌面问题,性质类似,一起记。
Wayland 下窗口能力是空的。 想让小工具固定在角落、置顶、不在任务栏出现——在 winit 的 Wayland 后端里,set_visible、set_outer_position、set_window_level 全是空实现(源码注释直接写 “Not possible on Wayland.”),set_minimized(true) 也无法取消。要做全功能得自己写 layer-shell 后端(估 800–1500 行新代码,且 GNOME 不实现该协议),实际选择是继续走 XWayland。
X11 下任务栏也不会自己让开。 KWin 不会因为窗口是 Utility 就跳过任务栏,而 winit 的 X11 后端又没有 skip_taskbar 接口(只有 Windows 有)。只能自己发 EWMH 消息:
1 | _NET_WM_STATE_SKIP_TASKBAR |
要注意窗口还没被窗口管理器接管时,这两条消息会被丢弃——发送后要回读 _NET_WM_STATE 确认,失败就重试(实践里最多重试 10 次、约 5 秒)。
KDE 维护两套 MIME 关联文件。 系统设置里改默认应用,一切换页面就回滚、双击文件还是老程序打开。原因是:KCM 和右键菜单只写 ~/.config/mimeapps.list,而运行时优先读 ~/.config/kde-mimeapps.list,系统级 /usr/share/applications/kde-mimeapps.list 又提供了默认值。三份文件叠在一起,只改 GUI 那份永远不生效。
定位方法很实用:把默认指向一个无界面的测试应用,看谁被真正拉起——由此确定运行时到底读的是哪份配置。
还有一层:「直接关联」优先于「父 MIME 继承」。一个 desktop 同时声明 text/plain;text/markdown;text/x-markdown 时,去重后目标 MIME 只剩继承关系,强度低于另一个直接声明它的应用——表现就是「设了默认却打不开」。
总结
三个方向,一句话各一条:
- 输入法是分层问题,环境变量、应用允许、输入法状态、框架帧序,缺一层就全废;测试要按框架的真实时序写,别去改产品迁就测试。
- 导出是一次小型排版:字体覆盖、CJK 断行、全角宽度、绘制状态,一条条补,补完才算能用。
- 性能先找「每帧做了什么全文工作」,再用内容版本号把它们挡住。
再补一条通用的:「设置没生效」先怀疑「要改的文件不止一处」。桌面环境尤其如此——GUI 写一份、运行时读另一份、系统级再给默认值,三份叠着,只改看得见的那份永远不会生效。