- 桌面应用
- 开发工具
【免费下载链接】MooTool
Handy tool set for developers
本文围绕 MooTool 桌面工具集的 Compose 重写工程(
next-compose)中一条典型的“焦点证据”改动记录(DIFF-428)展开,讲解该团队如何用ToolbarFocusCaptureTest在 CI 场景下回归MooCompactSearch组件 + modern 主题强调色(accent)焦点环的像素表现,并解释其与产品主窗 AX 帧、VaultRevisionMonitor基线重同步之间的协作关系。读完本文,你将理解一套可复用的“Compose 可访问性视觉证据”测试模式,包括焦点环 token 的来源、测试如何断言焦点环像素、证据帧的编号体系,以及为什么这类场景测试不能代替真实窗口手工验收。
背景:为什么需要一份 Compose 场景焦点环证据
MooTool 的next-compose分支在持续对齐原 Electron 版本与既有 Java 版本的视觉/交互行为。团队用一套DIFF 编号文档(见 next-compose/docs/diff/)逐条记录行为差异、改动内容与验证方式,而“Tab 焦点外描边(focus ring)是否在正确位置、使用正确颜色渲染”正是其中一类需要被持续回归的视觉细节。
在 DIFF-428 原文 中可以看到清晰的上下文:
- 产品主窗此前已有
118-http-search-tab-focus.png,这是一张AX(Accessibility)帧——即对真实运行窗口用screencapture/AX 坐标截图得到的画面; - 但产品窗帧依赖手工操作、耗时且不稳定,无法进入纯 CI 自动回归;
- 因此需要与 DIFF-427(JSON Vault 搜索焦点环)、DIFF-097(Tab 焦点帧体系起点) 一样,在
desktopTest中用ToolbarFocusCaptureTest生成一张Compose 场景帧,覆盖MooCompactSearch+ modern accent 焦点环,并以210dp宽度对齐真实 HTTP 集合列的布局。
一句话概括这条证据链的设计思想:“产品窗 AX 帧负责证明真实窗口里焦点环真的可见;Compose 场景帧负责在 CI 里自动防止焦点环 token 回归”,两者互补而不互相替代。
MooCompactSearch:被验证的紧凑搜索组件
HTTP 工具页的左侧是“集合”(已保存请求列表)列,顶部就是搜索框。真实调用发生在 HttpScreen.kt 的 CollectionPane:
MooCompactSearch( session.query, { session.query = it; onChanged() }, placeholder = container.t("common.search"), modifier = Modifier.weight(1f), )组件本体定义在 Controls.kt,其关键行为包括:
| 状态 | 边框颜色 | 说明 |
|---|---|---|
| 聚焦(focused) | colors.accent | 焦点时边框切换到强调色 |
| 悬停(hovered) | colors.borderControlHover | 悬停高亮边框 |
| 默认 | colors.borderControl | 常规控件边框 |
同时,MooCompactSearch通过Modifier.mooFocusOutline(focused, shape)叠加外描边焦点环,并且仅在interfaceStyle = "modern"且非smartisan/miui-v5风格时,聚焦状态还会额外加一层focusRing半透明阴影(alpha = 0.35f)。这就是 DIFF-428 中“modern accent 焦点环”一词的准确含义——焦点环与边框都取自主题强调色体系。
MooCompactSearch还特意预留了fieldModifier: Modifier = Modifier参数(DIFF-427 引入),目的就是让测试可以通过focusRequester(...)把焦点主动授予内部BasicTextField。这是整套场景帧测试能够稳定请求焦点的前提。
焦点环 token 从哪来:focusRing 与强调色推导
测试断言的颜色0x316DC0并非硬编码魔法值,而是主题 token 的取值。在 MooTheme.kt 中可以找到:
- light 色板:
focusRing = Color(0xFF316DC0)(见MooTheme.kt#L262); - dark 色板:
focusRing = Color(0xFF94BDF4)(#L302); - 运行时:
focusRing = accent.focusRing(#L710),即焦点环跟随用户选择的强调色; - 默认蓝色强调色:
AccentPalette(..., Color(0xFF316DC0), ...)(#L899)。
因此测试里写val expected = 0x316DC0等价于在 light modern 主题、默认蓝色强调色下断言“外描边环精确落在 focusRing token 上”,一旦主题 token 或mooFocusOutline实现走样,像素计数就会失败。
mooFocusOutline本身是一个自绘实现(见 Controls.kt):在内容之上用drawWithContent画一圈 2dp 描边,且描边与控件之间留 2dp 间距,从而形成“外描边(outline)”而不是紧贴的边框:
val stroke = 2.dp.toPx() val gap = 2.dp.toPx() val inset = -(gap + stroke / 2f) drawRoundRect( color = ring, topLeft = Offset(inset, inset), ... style = Stroke(width = stroke) )理解这一段,就能明白为什么断言用“命中像素数 ≥ 8”而不是“恰好 N 像素”——描边是沿圆角矩形绘制的弧线段,受字体渲染与抗锯齿影响,像素分布会变化,但焦点环颜色的像素一定大量存在。用下限断言可以兼顾“环必须可见”与“抗锯齿导致像素数浮动”。
测试拆解:captureHttpCollectionSearchFocusRing
DIFF-428 的核心交付就是ToolbarFocusCaptureTest.captureHttpCollectionSearchFocusRing。该测试类位于 next-compose/composeApp/src/desktopTest/kotlin/com/rememberber/mootool/next/compose/ui/workbench/ToolbarFocusCaptureTest.kt,整条测试的流程可以拆成五步:
- 搭场景:
runDesktopComposeUiTest(width = 280, height = 120)起一个 280×120 的 Compose 测试画布;MooTheme(preference = Light, interfaceStyle = "modern")固定使用 light + modern 主题,保证 focusRing 一定是0xFF316DC0。 - 渲染目标组件:以
Modifier.width(210.dp)渲染MooCompactSearch,placeholder = zh.t("common.search"),并通过fieldModifier.focusRequester(searchFocus)挂上FocusRequester、testTag("httpCollectionSearchField")。210dp 就是对齐 HTTP 集合列的真实宽度——这解释了 DIFF-428 标题里“210dp 宽对齐 HTTP 集合列”的含义。 - 请求焦点:
runOnIdle { searchFocus.requestFocus() }后waitForIdle(),让 Compose 完成焦点转移与重绘。 - 截图:
onRoot().captureToImage().toAwtImage()捕获画布并写为 PNG,输出到docs/evidence/2026-09-15-inspector-screencapture/windows/132-compose-http-collection-search-tab-focus.png;同时用File.length() > 500校验产物非空。 - 像素断言:
countRingPixels(image, 0x316DC0) >= 8,不足 8 个命中像素则测试失败,直接阻断回归。
其中countRingPixels是一个朴素的整图扫描(#L694-L702):遍历每个像素,取 RGB 与目标颜色比较(image.getRGB(x, y) and 0x00FFFFFF == rgb)。这张 210×62 的证据帧本身也证实了测试画布最终被裁剪为 210dp 宽、62px 高的紧凑尺寸。
输出图片:132-compose-http-collection-search-tab-focus.png(Compose 场景中 210dp 宽MooCompactSearch的 accent 焦点环帧)。
作为对照,产品主窗同一位置的 AX 帧是 118-http-search-tab-focus.png(真实窗口截图,HTTP 页集合搜索可见焦点环)。
证据目录 results.md 对这两帧的定位写得很明确:132是“Compose 场景 HTTP 集合MooCompactSearch(210dp,DIFF-428,与118互补)”,即产品窗帧与 Compose 场景帧互为补充。
从 127 到 132:一套可复用的证据帧模式
ToolbarFocusCaptureTest并不是只为 HTTP 集合搜索写的孤立测试,而是一个不断扩展的回归家族。同文件里还包含了:
captureCommandSearchAndJsonCopyFocusRings→ 帧127/128(命令盘搜索占位、JSON 复制按钮,见 DIFF-097);captureJsonVaultSearchFocusRing→ 帧131(JSON Vault 搜索,见 DIFF-427);captureHostProfileSearchFocusRing→ 帧133(Host 方案列表搜索,220dp,见 DIFF-429);captureHttpSendAndTranslationNowFocusRings→ 帧129/130(HTTP 发送、翻译按钮的p5Toolbar焦点环,见 DIFF-107)。
所有用例共用同一套“FocusRequester → requestFocus → captureToImage → countRingPixels”骨架,只是组件、宽度与目标颜色不同。这意味着:一旦在 UI 层新增一个可聚焦控件,都可以按同样的模板补一条场景帧测试,而不必重新设计验证方案。
这套帧的编号体系与产品窗帧(如116~126)区分开:127之后的编号专门留给 Compose 场景,避免与手工截取的产品窗 AX 帧混淆。同时文档反复强调(见 ToolbarFocusCaptureTest.kt):这是 Compose 场景帧,不是产品主窗,也不能代替系统 IME 走查。
顺带清理:JSON Vault 新建文件对话框的冗余 noteOwnWrite
DIFF-428 还携带了一个看似无关、实则同一治理主题的清理项:“JSON Vault 新建文件对话框去掉冗余noteOwnWrite(onSuccess已rebaselineAfterLocalCrud)”。
要理解这句话,需要回顾 Vault 磁盘监视器的工作机制。VaultRevisionMonitor(见 VaultRevisionMonitor.kt)以 400ms 间隔轮询 Vault 目录快照(snapshot()对每个文件计算 SHA-256 指纹),再与上一轮previous做diff()。如果本机自己的写盘动作不登记,就会被下一轮轮询当作“外部变更”,触发多余的notify*VaultTreeChanged与 Git 角标刷新。
两个配套手段分别是(见 VaultMonitorOwnWrite.kt):
noteOwnWriteJsonVaultFile(vault, path):写盘后把“相对路径 → 新哈希”登记进expectedHashes,下一轮轮询命中即剔除该路径,不当作外部变更(DIFF-422 起引入);rebaselineAfterLocalCrud():包装VaultRevisionMonitor.rebaseline(),直接把previous = snapshot()整体重拍基线并清空expectedHashes,适用于删除、移动、重命名等路径集合发生变化的 CRUD 场景(DIFF-425 起引入)。
而 JSON Vault 新建文件的确认逻辑在 JsonScreen.kt:runCatching { ... container.jsonVault.createFile(path, body) ... }的onSuccess分支统一调用monitor.rebaselineAfterLocalCrud()。新建文件属于“新增路径”,rebaseline已经覆盖了整棵树的基线,之前额外单独noteOwnWrite同一路径就成了冗余操作——这正是 DIFF-428 清理的冗余。这个改动遵循 DIFF-425/426 确立的原则:“同路径保存/单文件导入仍用noteOwnWrite;新建/删除/移动/重命名等路径集合变化统一走rebaseline”。
验证:一条命令回归整套证据
DIFF-428 的验证入口是:
./gradlew :composeApp:desktopTest --offline在 DIFF-428 记录时,本机该命令全量为534/534通过(JDK 21,离线模式);此后该测试类继续演进,到 DIFF-097 之后全量已增长到 263/263、后续切片阶段更扩展至千余用例(见 acceptance.md),但每次改动记录都会标注当时的全量通过数,保证证据可追溯。也可以只跑焦点类测试做快速定位:
./gradlew :composeApp:desktopTest --offline --tests '*ToolbarFocusCaptureTest*'这一模式的优点在于:--offline保证构建不依赖网络;captureToImage在无头/桌面 Compose 测试环境中即可运行,因此像素断言可以作为常规单测进入 CI,而不是依赖人工截图流程。
结论与边界
DIFF-428 代表了一种可复制的工程实践:把“看得见的 UI 行为”编码为可自动断言的证据帧。其要点可归纳为:
- 用
MooCompactSearch.fieldModifier暴露focusRequester,让测试能主动聚焦内部输入框; - 用
MooTheme(preference = Light, interfaceStyle = "modern")固定主题,使focusRing稳定为0xFF316DC0,从而可以用像素计数断言焦点环; - 以
210dp复刻真实 HTTP 集合列宽度,让场景帧与产品窗帧118具有可比性; - 证据帧写入
docs/evidence/2026-09-15-inspector-screencapture/windows/,与127–133系列场景帧形成统一编号体系,并与 AX 帧互补; - 同时治理 Vault 监视器的写盘登记策略,避免“本机动作被误判为外部变更”。
同时必须强调其边界:这些帧是 Compose 场景帧,只能回归 UI token 与焦点环渲染,不能代替产品主窗 AX 帧,更不能代替系统 IME、托盘 TCC 等需要真实窗口与系统能力的手工验收。这正是 acceptance.md 中“Compose UI 测试不能代替 Swing 编辑器内部节点与安装镜像验收”的层级原则在焦点验证上的具体体现。
- 桌面应用
- 开发工具
【免费下载链接】MooTool
Handy tool set for developers
相关推荐
MooTool Next Compose 的 JSON Vault 搜索框 Tab 焦点环回归:`MooCompactSearch.fieldModifier` 与像素断言证据实践
MooTool Next Compose 的 JSON Vault 搜索框 Tab 焦点环回归: MooCompactSearch.fieldModifier
桌面应用开发工具MooTool Next Compose Vault Git 远程仓库同步与 Compose 场景 Tab 焦点回归指南
MooTool Next Compose Vault Git 远程仓库同步与 Compose 场景 Tab 焦点回归指南 导读 本文以 MooTool Next
桌面应用开发工具MooTool 命令盘关闭按钮 Tab 焦点序对齐:Compose 端 DIFF-492 实现与验证解析
MooTool 命令盘关闭按钮 Tab 焦点序对齐:Compose 端 DIFF 492 实现与验证解析 导读 本文围绕 MooTool Compose(Jet
桌面应用开发工具
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考