<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[给宏编辑器加一个图形化模式：两天、25 轮迭代，和一次 0xC00000FD]]></title><description><![CDATA[<p dir="auto">「加个图形化编程页麻烦吗？」</p>
<p dir="auto">这是 10 月 20 日中午我收到的一句话。当时的回答是：不麻烦，但得先看清手里有什么——因为图形化编辑器最容易做成的样子，是画得挺好看、生成出来的脚本跑不了。</p>
<p dir="auto">两天之后，它长这样：块可以拖、可以拼、能撤销、能复制粘贴、能按 Ctrl 滚轮缩放，切回文本模式看到的就是块生成的规范化脚本，编译、校验、保存、烧录全部复用原来的链路。</p>
<p dir="auto"><img src="https://s3.phubber.com:19443/bbs/uploads/2026/10/1792598042812-1583995f-block-editor.png" alt="键鼠助手 5.0 的图形化编程界面：左侧宏目录，右侧块画布" class="img-markdown  img-fluid" /></p>
<p dir="auto"><em>成品：左边是宏目录，右边是块画布。中间那段 <code>当 … 执行</code> 是容器块，条件由值块拼成（<code>count 小于 3 且 鼠标是否按下 1</code>），下面跟着延时、赋值、移动、点击，最后一段是自定义函数 <code>on_timer1</code> 的定义块。</em></p>
<p dir="auto">这篇文章记录这两天的实际过程：做了哪些决定、踩了哪些坑、时间花在哪。文末有一张按开发日志回填的时间账。</p>
<hr />
<h2>一、先别写代码，先盘点</h2>
<p dir="auto">动手之前先回答一个问题：这个「Lua 宏」到底是不是 Lua。</p>
<p dir="auto">盘完的结论是——不是。它是一个封闭的小 DSL，解析器同时支持两套方言：</p>
<ul>
<li><strong>Lua 风格</strong>（对外文档化的语法）：<code>local x = v</code>、<code>if..then..elseif..else..end</code>、<code>while..do..end</code>、<code>for i=a,b,step..do..end</code>、<code>function f(p)..end</code>、<code>do..end</code>、<code>return</code>、<code>break</code>、<code>--</code> 注释；</li>
<li><strong>遗留 BASIC 风格</strong>（老脚本兼容）：<code>If/Then/EndIf</code>、<code>While/Wend</code>、<code>For/Next</code>、<code>Sub/End Sub</code>、<code>Dim</code>、<code>Goto</code>、<code>Rem</code>、<code>Call</code>、<code>Exit Sub</code>。</li>
</ul>
<p dir="auto">内置能力面是两张静态表：15 条命令 + 9 个函数，每条都带分类、参数模式、参数种类、参数个数上下限、说明文本。</p>
<p dir="auto">这个盘点值钱的地方在于它直接给出了方案：<strong>块调色板不用手写，可以从命令表派生</strong>；而唯一的缺口是 <strong>AST → 文本生成器（unparser）</strong>，得新写。</p>
<p dir="auto">盘点还顺手圈出一条红线：内置函数的编号是编译器按命令表声明顺序写进字节码的 uint16 操作数，<strong>只能在表末尾追加，不能插入或删除</strong>。所以图形化这一层绝不能碰编译链路，它只能是「块树 → 文本 → 原有 parser/compiler」的上面一层。</p>
<blockquote>
<p dir="auto">一个决定性的判断：图形化只是<strong>入口</strong>，不是新引擎。这个边界守住了，后面所有工作都只在一个 UI 模块里发生，设备端字节码、VM、函数编号零改动。</p>
</blockquote>
<h2>二、第二条决断：不塞 Web 引擎</h2>
<p dir="auto">做块编辑器最省事的路是塞一个 Web 引擎进去，跑现成的 Blockly。这条路被否掉了，理由很实际：</p>
<ul>
<li>安装包会凭空涨几十兆，启动慢一截；</li>
<li>多一个运行时的版本问题（Qt WebEngine 的 Chromium 更新、显卡驱动兼容）；</li>
<li>复制粘贴、字体渲染、高分屏缩放全部隔着两层，和原生控件的行为对不齐。</li>
</ul>
<p dir="auto">于是决定：<strong>原生 Qt 自绘</strong>，视觉和几何照 Blockly 移植。代价是每一根连接齿、每一段圆角都要自己算；收益是这套东西完全可控——后来做缩放时，「所有几何都在逻辑坐标里」这件事能成立，正是因为画布是我自己的。</p>
<h2>三、布局和绘制必须分开</h2>
<p dir="auto">一开始是很朴素的一层：一个函数量尺寸、一个函数画。很快就撞墙了——插入线要落点、命中测试要知道点到哪个参数、内嵌的编辑框要贴到某个字段上，这些全都要「尺寸从哪来」。</p>
<p dir="auto">所以最终分成三层：</p>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>层</th>
<th>职责</th>
<th>为什么不合并</th>
</tr>
</thead>
<tbody>
<tr>
<td><code>block_render_info</code></td>
<td>纯几何与文案，算出一棵布局树</td>
<td>命中测试、插入线、内嵌控件几何全都复用它，不能只有绘制时才存在</td>
</tr>
<tr>
<td><code>block_drawer</code></td>
<td>只按布局树画，不改任何状态</td>
<td>绘制无副作用，缩放时才能只换一个变换矩阵</td>
</tr>
<tr>
<td><code>block_render_constants</code></td>
<td>齿高、圆角、行高、内衬这些形状常量</td>
<td>改一版视觉只动一处</td>
</tr>
</tbody>
</table>
<p dir="auto">撞墙的具体形态是「凹凸连接件不吻合」：上一版齿的曲率半径和缺口对不上，块叠起来有一条白缝。最后是对着参考实现的源码逐段校正几何参数才解决——这也是第一天晚上 20:25 那条日志的由来。</p>
<p dir="auto">右侧的块目录直接复用了同一套绘制代码，所以「面板里长什么样」和「拖进画布后长什么样」不会有第二份实现：</p>
<p dir="auto"><img src="https://s3.phubber.com:19443/bbs/uploads/2026/10/1792598053779-6c1f7520-block-palette.png" alt="右侧块目录：每个条目就是一个缩小的块，带参数名与说明" class="img-markdown  img-fluid" /></p>
<p dir="auto"><em>块目录的条目不是截图，也不是另画的示意图——它调用的是画布那套 <code>block_render_info</code> + <code>block_drawer</code>，只是画在一个小画布上。</em></p>
<h2>四、真相只能有一个</h2>
<p dir="auto">命令表原本只有一个 <code>displayName</code>，那是<strong>文本模式里真实可键入的调用名</strong>，不能改。但块面上要显示中文名，右侧目录也要中文名。</p>
<p dir="auto">一开始的写法是在块的渲染里硬编码一套中文。这套东西立刻就脱轨了：同一个命令，块面上叫「按下按键」，目录里叫「按下」，诊断里又叫「按键按下」。改一处，另两处不知道。</p>
<p dir="auto">改成：命令表上加一个 <code>chineseName</code>，块面、目录、提示共用；文本模式继续用 <code>displayName</code>。<strong>一处声明，三处消费</strong>。</p>
<p dir="auto">同样的思路后来用在表达式上，而且更极端：</p>
<ul>
<li><strong>槽里的文本是唯一真相</strong>，AST 是它的一次投影，槽文本变了才重建；</li>
<li>拖一个值块进来，是<strong>替换子树</strong>，不是往字符串里追加。</li>
</ul>
<p dir="auto">第二条是血的教训。最早为了省事，「把函数拖进条件槽」实现成往槽的文本里拼字符串，结果 <code>a + 1</code> 拖两次变成 <code>a + 1 + 1</code>，而且撤销回去再看，表达式树已经和文本对不上了。结构化之后这类问题整类消失：拖拽只发生在树的两颗子树之间，文本只是它的输出。</p>
<h2>五、剪贴板：不要用文本当中间格式</h2>
<p dir="auto"><code>Ctrl+C / Ctrl+V</code> 的第一版想法很自然：把选中的块生成脚本文本，粘贴时再解析回来。这条路走不通，因为文本层丢了信息：</p>
<ul>
<li>「可选参数槽<strong>未启用</strong>」和「槽里<strong>是空的</strong>」在文本里长得一样；</li>
<li><code>if</code> 分支的启用状态、被折叠的分支，文本表达不了；</li>
<li>往返一次，块的类型信息（哪一段是用户子程序调用、哪一段是自定义函数）就退化成函数名。</li>
</ul>
<p dir="auto">于是剪贴板走的是「整棵块树序列化」：自定义 MIME 类型 <code>application/x-kma-blocks</code>，内容是 JSON，带格式版本号。同时<strong>副带一份等价脚本文本</strong>——这样粘到记事本里也讲得清，从纯文本粘回来时走 parser 兜底，至少不会丢得莫名其妙。</p>
<blockquote>
<p dir="auto">一个可以复用的结论：块的复制粘贴不要走「块 → 文本 → 块」。文本是给人看的产物，不是传输格式。</p>
</blockquote>
<h2>六、那圈看不见的选中框</h2>
<p dir="auto">多选本身不难：按住 Ctrl 点一下切换选中，选中集合是个保序去重的数组，主选中恒等于第一个（粘贴位置、调色板新块都跟着它）。真正的坑在「看不见」。</p>
<p dir="auto">选中框最初用的是主题的高亮色。逻辑上完全正确——主题换肤自动跟随。但在浅色主题下，那个高亮是<strong>淡蓝的 1px 细线</strong>，压在蓝色的块体上，肉眼几乎无法分辨。用户反馈是「按了 Ctrl 点，不知道有没有选中」。</p>
<p dir="auto">最后改成固定配色：外圈琥珀 3px，内圈深棕 1px 做内衬，深浅主题下都压得住。教训是<strong>主题色不等于可见色</strong>，选中这种强反馈的视觉不要交给定主题去决定。</p>
<h2>七、缩放：所有几何只能有一套坐标系</h2>
<p dir="auto">这是整个两天里最值得写的一段。</p>
<p dir="auto">缩放最直觉的做法是「把所有尺寸都乘一下系数」。这么做会在三个地方同时崩：命中测试、拖拽落点、内嵌编辑框的位置。因为一旦有两套坐标，任何一处忘了换算，就是「看着点在 A 上、选中了 B」。</p>
<p dir="auto">所以定了一条规矩：<strong>布局、命中、拖拽，全部在逻辑坐标里算；缩放只作用在两个入口</strong>——绘制的 <code>QPainter::scale()</code>，和把鼠标位置换算成逻辑坐标的那一个函数。这条规矩守住之后，缩放本身变得很简单。</p>
<p dir="auto">真正难的是那些「看起来该跟着缩，但其实不能缩」的东西：</p>
<ul>
<li><strong>布局宽度取视口宽度，且与缩放无关</strong>。这条是「真缩放」和「只改字号」的分界线。如果布局宽度写成「视口宽 ÷ zoom」，那缩放只是在改字号，块永远不会横向溢出，也就永远不需要横向滚动；</li>
<li><strong>内嵌的输入框和下拉框是子控件，不缩放</strong>。所以既要按缩放换算它们的位置和尺寸，又要按缩放调字号，两处都得乘；</li>
<li><strong>滚动条的单位是逻辑像素</strong>。滚到一半改变缩放时，要先记住「当前视口顶端的逻辑坐标」，重排之后按这个锚点恢复，否则内容会突然跳走。</li>
</ul>
<p dir="auto">滚轮以光标为锚点缩放，也是同一个套路：算出光标下的逻辑点，缩放后反解出新的滚动偏移，让那个点停在原地。</p>
<p dir="auto">再加两条写进代码注释的自我约束：</p>
<ul>
<li>布局逻辑宽度有下限（560），窗口极窄时不至于把块挤成一条竖线；</li>
<li>缩放范围 40%～300%，一格 1.15 倍。连续滚动是乘方关系，所以是平滑的，一格一档也不会跳。</li>
</ul>
<h2>八、两个让我半夜还在查的 bug</h2>
<h3>1. 300% 缩回 100% 直接崩：0xC00000FD</h3>
<p dir="auto">现象是必崩：放大到 300% 再缩回 100%，进程退出码 <code>0xC00000FD</code>——栈溢出。</p>
<p dir="auto">原因很朴素：<code>refreshLayout()</code> 末尾调了 <code>resize()</code>，而 <code>resize()</code> 会<strong>同步</strong>回调 <code>resizeEvent</code>，回调里又调 <code>refreshLayout()</code>。放大时内容尺寸单调增长，递归很快就耗尽了栈。</p>
<p dir="auto">修法是加一个重入闸（<code>refreshing_</code>），进入布局就先置位、退出再复位，回调进来直接返回。</p>
<p dir="auto">值得说的是为什么它没被测出来：早期的截图测试窗口尺寸是固定的，缩放也只在同一个尺寸内做，递归深度刚好没到阈值。<strong>是「在真实窗口里来回缩」这个动作把它逼出来的</strong>，不是断言。</p>
<h3>2. 一个测试程序完全没有输出</h3>
<p dir="auto">另一个更折磨人：新写的缩放测试程序一启动就死，连 <code>main</code> 第一行的 printf 都没有。</p>
<p dir="auto">排查绕了很久，最后是靠「第一行都没打印」这个线索反推的——不是逻辑崩，是<strong>进程在第一次输出之前就没了</strong>。罪魁是这一行：</p>
<pre><code class="language-cpp">std::setvbuf(stdout, nullptr, _IOLBF, 0);
</code></pre>
<p dir="auto">想把 stdout 改成行缓冲，<code>size</code> 传了 0。这在 MSVC 的 CRT 里会直接把进程打死。给一个真实缓冲区就好了。</p>
<blockquote>
<p dir="auto">记录下来是因为这条非常有欺骗性：现象是「程序什么都不做就退出」，看起来像链接错了、像静态初始化崩了、像 Qt 插件找不到，唯独不像一行日志相关的调用。</p>
</blockquote>
<h2>九、回归测试搬回仓库</h2>
<p dir="auto">这套东西的正确性没法靠眼睛看——块的几何、命中、嵌入、撤销，随便改一行都可能悄悄坏掉。之前这些验证散在构建目录下的临时程序里，改一次代码要手工找一遍入口。</p>
<p dir="auto">这次把它们收进仓库，做成一件可重复的事：</p>
<ul>
<li>一份用例清单是唯一真相（类型 | 可执行文件 | 模式 | 最少断言数 | 参数形式 | 说明），两个平台的脚本共读同一份；</li>
<li>三种类型：断言型（12 个，判定依据）、冒烟型（7 个，只要求不崩）、截图型（8 个，默认不跑，出图人工比对）；</li>
<li><strong>最少断言数</strong>是必需的防御：模式名写错会走到默认分支静默返回 0，看起来「通过」，其实是没跑；</li>
<li>两个平台各一个脚本，退出码统一：0 全通过 / 1 有用例失败 / 2 环境或构建失败。失败不中断，最后统一汇总。</li>
</ul>
<p dir="auto">跑通的结果是 19 通过 / 0 失败 / 8 跳过，截图模式 8/8 出了 18 张图。</p>
<p dir="auto">跨平台脚本这部分，坑比功能代码还多：</p>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>坑</th>
<th>表现</th>
<th>处置</th>
</tr>
</thead>
<tbody>
<tr>
<td>批处理文件存成 UTF-8</td>
<td>中文 Windows 上按系统代码页逐行解析，行边界错乱，报「xxx 不是内部或外部命令」</td>
<td>存成 ANSI/GBK</td>
</tr>
<tr>
<td>用 for /f 读 UTF-8 清单</td>
<td>中文重配对把分隔符吞掉，整行被静默跳过（用例凭空少三条）</td>
<td>先按 UTF-8 读、只取纯 ASCII 列，生成工作清单</td>
</tr>
<tr>
<td>用 find 计数</td>
<td>PATH 里装了 GNU find，<code>/c</code> 被当成 C 盘递归扫描，CPU 打满</td>
<td>计数交给专用脚本</td>
</tr>
<tr>
<td>shell 里 <code>kill -0</code> 判存活</td>
<td>僵尸进程仍然返回成功，每条用例都等满超时</td>
<td>改判进程状态里的 <code>Z</code></td>
</tr>
</tbody>
</table>
<h2>十、148 处硬编码文案</h2>
<p dir="auto">最后一件收尾的事：英文界面下，块编辑器整片是中文。因为绘制层是命名空间里的自由函数，没有 QObject，拿不到 <code>tr()</code>，所以文案全是硬编码字面量。</p>
<p dir="auto">处置分两步：</p>
<ol>
<li>造一个只有 <code>tr()</code> 的壳类（<code>Q_DECLARE_TR_FUNCTIONS</code>），上下文固定，调用点统一写 <code>BlockText::tr(...)</code>。148 处字面量批量替换，只换函数名，跨行拼接的参数原样保留；替换前先确认这些字面量<strong>没有一处参与比较、持久化或生成脚本</strong>——否则翻译会让脚本跟着变语言。</li>
<li>重扫翻译文件、补齐译文、重新生成 <code>.qm</code>。结果是两边各 478 条、0 条未完成。</li>
</ol>
<p dir="auto">顺带发现两件事值得写下来：</p>
<ul>
<li>旧翻译文件严重滞后，中文界面此前有一部分实际显示的是英文（表格列名、设备类型这些）。这次一并补上；</li>
<li><strong>提取工具必须一次给全量源目录</strong>。只对单个目录跑，工具会把其余 478 条判成「已废弃」直接删掉——我被清空过两次，靠备份才恢复。这是那种「操作成功、结果正确、数据没了」的典型。</li>
</ul>
<h2>十一、时间账</h2>
<p dir="auto">下面这张表是按开发日志的推进节点回填的，不是秒表计时，误差大概在半小时量级。所谓「一轮」= 一次完整的改→编→验证→截图确认。</p>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>日期</th>
<th>时段</th>
<th>内容</th>
<th>轮数</th>
</tr>
</thead>
<tbody>
<tr>
<td>10-20</td>
<td>12:35 起约 45 分钟</td>
<td>盘点 DSL、定边界、确认唯一缺口</td>
<td>—</td>
</tr>
<tr>
<td>10-20</td>
<td>下午约 4.5 小时</td>
<td>块模型 / 文档 / 画布 / 页面 + AST 转文本</td>
<td>1</td>
</tr>
<tr>
<td>10-20</td>
<td>傍晚约 2.5 小时</td>
<td>视觉移植：C 形嘴部、齿形吻合、块上直改</td>
<td>3</td>
</tr>
<tr>
<td>10-20</td>
<td>晚间约 2.5 小时</td>
<td>渲染层按参考源码逐段校正、拖动、撤销重做</td>
<td>3</td>
</tr>
<tr>
<td>10-21</td>
<td>上午约 2 小时</td>
<td>表达式结构化：值块、且/或/非、取负、拖放替换</td>
<td>5</td>
</tr>
<tr>
<td>10-21</td>
<td>中午约 3.5 小时</td>
<td>对齐、分隔条、右栏分类、形态开关、值块选中</td>
<td>5</td>
</tr>
<tr>
<td>10-21</td>
<td>下午约 2.5 小时</td>
<td>定位、右键分流、死按钮、图标定稿</td>
<td>4</td>
</tr>
<tr>
<td>10-21</td>
<td>晚间约 3.5 小时</td>
<td>剪贴板、多选、缩放</td>
<td>3</td>
</tr>
<tr>
<td>10-21</td>
<td>21:00–22:30</td>
<td>回归整合 + 两个平台的一键脚本</td>
<td>1</td>
</tr>
<tr>
<td>10-21</td>
<td>22:30–23:10</td>
<td>文案翻译收尾</td>
<td>1</td>
</tr>
<tr>
<td></td>
<td><strong>合计</strong></td>
<td><strong>两个工作日，净投入约 24 小时</strong></td>
<td><strong>约 25 轮</strong></td>
</tr>
</tbody>
</table>
<p dir="auto">产出的体量：33 个文件、+10617 / −539 行。其中纯功能代码大概占七成，剩下是测试、翻译资源和文档。</p>
<p dir="auto">如果把时间按性质拆开，比例大概是这样：</p>
<table class="table table-bordered table-striped">
<thead>
<tr>
<th>类别</th>
<th>占比</th>
<th>具体是什么</th>
</tr>
</thead>
<tbody>
<tr>
<td>写功能</td>
<td>~55%</td>
<td>模型、布局、绘制、交互、表达式</td>
</tr>
<tr>
<td>排障</td>
<td>~20%</td>
<td>栈溢出、隐式共享深拷贝、坐标没换算</td>
</tr>
<tr>
<td>构建与验证</td>
<td>~15%</td>
<td>编译、跑回归、看截图、来回缩</td>
</tr>
<tr>
<td>测试与文案</td>
<td>~10%</td>
<td>用例、脚本、翻译</td>
</tr>
</tbody>
</table>
<h2>十二、回头看</h2>
<p dir="auto">真正花时间的从来不是「把块画出来」。画块是第一天的下午就完成的事。</p>
<p dir="auto">耗时间的是三件事：</p>
<ol>
<li><strong>坐标系</strong>。只要有两套坐标，就一定会有一处忘了换算。把它压到一套，缩放从「到处乘以系数」变成了两个入口的改动。</li>
<li><strong>真相只有一个</strong>。块的文案、槽的文本、剪贴板的格式，每一处都有「省事的双份实现」诱惑，而每一处双份最后都脱轨了。</li>
<li><strong>验证要能重复</strong>。改一行就可能悄悄坏掉的模块，靠眼睛看是靠不住的；把验证做成一条命令，才有勇气去动第二十五轮。</li>
</ol>
<p dir="auto">至于「两天」这个数字——它更像两天 × 大约十二小时，中间还有不少时间花在等编译和重跑回归上。真要说有什么经验，是那条最初的红线救了整件事：图形化只是入口，不碰编译链路。守住这条线，才敢在两天里迭代二十五轮。</p>
<hr />
<p dir="auto"><em>本文涉及客户名称、内部路径、内网地址与版本标识的细节已做脱敏处理。</em></p>
]]></description><link>https://phubber.com/topic/15/给宏编辑器加一个图形化模式-两天-25-轮迭代-和一次-0xc00000fd</link><generator>RSS for Node</generator><lastBuildDate>Tue, 27 Oct 2026 12:01:14 GMT</lastBuildDate><atom:link href="https://phubber.com/topic/15.rss" rel="self" type="application/rss+xml"/><pubDate>Wed, 21 Oct 2026 15:54:42 GMT</pubDate><ttl>60</ttl></channel></rss>