- 桌面应用
- 开发工具
【免费下载链接】MooTool
Handy tool set for developers
MooTool 的桌面版基于 Compose Multiplatform 重写(next-compose),其中"命令盘/分组弹层改为应用内遮罩"(DIFF-057)记录了搜索与分组管理弹层从系统半透明窗口迁移到主窗口内固定遮罩的关键决策。本文以该设计文档为骨架,结合 MooOverlay.kt、Workbench.kt、ThemeContrastTest.kt 等源码与测试,完整还原其设计背景、遮罩配色体系、布局规则、交互行为与配套验证,并给出可直接检索与复用的实现要点。
一、变更背景:Electron 原行为与本产品目标
1.1 Electron 的固定遮罩
在 Electron 版 MooTool 中,命令搜索(command palette)与分组管理均以应用壳内 fixed 遮罩呈现:
- 遮罩色为
rgba(0,0,0,.2),深色主题或 hero 风格下更深; - 面板使用
surface-elevated的不透明表面; - 点击遮罩即可关闭弹层;
- 这些弹层不是独立透明系统窗,始终位于应用主窗内。
对应 CSS 选择器为 Electron 侧的command-palette-backdrop与.dialog。
1.2 本产品(Compose 重写版)行为
next-compose遵循同一设计目标,但落地为全新的 Compose 实现:
- 弹层画在 Workbench 内,由
MooOverlay承载,遮罩来自overlayScrim()(浅色 0.20 / 深色 0.46),点击遮罩关闭; - 命令盘靠上(距顶约 96dp),分组管理器居中;面板一律用
workspace实底; - 删除确认仍是第二层遮罩(叠加在分组管理器之上),不关闭管理器本身;
- 彻底弃用 Compose
Dialog弹出独立半透明窗的做法(此前主窗截图会出现命令盘/分组与首页叠在一起的问题)。
该变更影响两个核心区域:A02 命令搜索与 A01 分组管理器。
二、为什么不用 Compose Dialog:技术决策依据
设计文档给出的理由非常直接:
Electron 弹层属于壳,不离开主窗口。系统
Dialog在 macOS 上半透明,截到的命令盘/分组与首页叠在一起,也不符合点遮罩关闭。
也就是说,选择应用内遮罩而非系统Dialog有三个现实原因:
- 截图穿透:macOS 上 Compose
Dialog是独立半透明窗口,主窗内容会透出下层,导致命令盘/分组与首页视觉叠加; - 交互不符:系统窗口与"点遮罩关闭"的应用内行为不一致;
- 层级控制:应用内遮罩能精确控制与侧栏、工具区、Swing 编辑器的 z-order 关系(详见后文
ModalOverlayState机制)。
2.1 同类演进:工具内 Dialog 同样被替换
这条设计路线并非只针对命令盘与分组管理器。受影响的后续变更包括:
- DIFF-059:JSON / 随手记 / HTTP / 图片 / 调色板 / Host / 环境变量 / 翻译 / 运行台 / PDF / 正则 / Cron / 时钟 的 Compose
Dialog全部改为MooOverlay; - DIFF-058:历史 / Vault Git / 冲突弹层也改为 overlay,并解决与 Swing 编辑器的互斥;
- DIFF-060:主窗退出确认同样收敛为
MooOverlay。
由此可见,MooOverlay是整套重写版桌面 UI 的统一弹层基元,而非一次性特例。
三、MooOverlay 实现剖析
MooOverlay.kt 是整个方案的实现核心。它对外暴露两个部分:ModalOverlayState(全局弹层计数)与MooOverlay组合函数。
3.1 ModalOverlayState:全局遮罩计数
object ModalOverlayState { var count by mutableStateOf(0) private set fun enter() { count++ } fun leave() { count = (count - 1).coerceAtLeast(0) } }这个单例维护当前打开的应用内遮罩数量。MooOverlay在组合进入时调用enter(),在onDispose时调用leave(),形成生命周期对称。
该计数被 EditorHost.kt 消费:
if (ModalOverlayState.count > 0) { Box(modifier.fillMaxSize().background(colors.workspace)) } else { SwingPanel(factory = { buffer.scrollPane... }, ...) }即:任何 overlay 打开时,EditorHost暂时卸下SwingPanel(AWT 编辑器),换成一个纯 Compose 的workspace色占位;关闭后恢复同一个EditorBuffer。原因是 Compose Desktop 的SwingPanel是重型 AWT 组件,会盖在同窗口的 Compose 遮罩之上抢走点击——这正是 DIFF-058 记录的"分组弹层盖在 JSON/HTTP 上时取消/保存点不到"问题的根治方案。
3.2 MooOverlay 组合函数
@Composable fun MooOverlay( onDismiss: () -> Unit, alignment: Alignment = Alignment.Center, contentPadding: PaddingValues = PaddingValues(24.dp), fillMaxSize: Boolean = false, content: @Composable () -> Unit )关键行为:
| 参数/行为 | 说明 |
|---|---|
onDismiss | 点遮罩或按 Esc 时回调,由调用方决定关闭哪个弹层 |
alignment | 面板对齐方式,命令盘用Alignment.TopCenter,分组管理器用默认居中 |
contentPadding | 面板外边距,命令盘传PaddingValues(top = 96.dp, ...) |
fillMaxSize | 是否让面板占满整窗(工具内弹层常用) |
| 遮罩 | Modifier.fillMaxSize().background(scrim).clickable(onClick = onDismiss) |
| Esc | onPreviewKeyEvent中拦截Key.Escape,并配合blockedByIme()避免 IME 输入法编辑时误关 |
| 面板 | 实底、圆角、consumeClicks消费点击,防止点面板内部冒泡到遮罩关闭 |
源码结构上,MooOverlay是两层Box:
- 外层全屏
Box负责 Esc 拦截; - 内层第一个
Box是遮罩,fillMaxSize().background(scrim).clickable(...); - 第二个
Box按alignment或fillMaxSize布局面板,并用consumeClicks拦截面板自身的点击,避免误触关闭。
3.3 面板表面:mooDialogSurface
面板样式由 MooOverlay.kt 中的mooDialogSurface提供:
fun Modifier.mooDialogSurface(radius: Dp? = null): Modifier { val shape = RoundedCornerShape(radius ?: MooTheme.dimens.dialogRadius) val border = when (colors.styleId) { "smartisan", "miui-v5" -> colors.borderControl else -> colors.borderSoft } return this .shadow(24.dp, shape, ambientColor = colors.shadow, spotColor = colors.shadow) .clip(shape) .background(colors.workspace) .border(1.dp, border, shape) }要点:
- 实底:背景用
colors.workspace(与 DIFF-060 对齐的 chrome token 体系); - 24dp 阴影 + 1dp 边框:视觉上区别于页面内容;
- 边框 token 按风格差异化:smartisan / miui-v5 用
borderControl,其余用borderSoft; - 圆角默认取
MooTheme.dimens.dialogRadius(各风格不同,如 modern 13dp、hero 16dp、miui-v5 8dp,见 ThemeContrastTest.kt 的chromeRadiiMatchElectronStyleBlocks)。
四、遮罩配色体系:overlayScrim
4.1 六套风格的 alpha 设计
MooTheme.kt 的overlayScrim()是遮罩色的唯一来源:
fun overlayScrim(): Color { val dark = workspace.luminance() < 0.5f return when (styleId) { "hero" -> Color(0xFF09090B).copy(alpha = 0.46f) "smartisan" -> Color(0xFF231F1B).copy(alpha = 0.42f) "miui-v5" -> Color.Black.copy(alpha = 0.38f) "claude" -> Color(0xFF2D2520).copy(alpha = 0.34f) else -> Color(0xFF121214).copy(alpha = if (dark) 0.46f else 0.24f) } }对应文档所述"浅色 0.20 / 深色 0.46"的精确实现:默认风格(modern / quiet 等)浅色遮罩 alpha 为 0.24,深色为 0.46;hero 在浅色下也达到 0.46(与 Electron 侧"深色/hero 更深"对应)。
各风格遮罩 alpha 汇总(由源码与 ThemeContrastTest.kt 交叉确认):
| 风格 | 遮罩色 | alpha | 测试断言 |
|---|---|---|---|
| modern(浅色) | 0xFF121214 | 0.24 | 0.24f ± 0.02 |
| modern(深色) | 0xFF121214 | 0.46 | 全部风格>= 0.2f,深色>= 0.34f |
| hero | 0xFF09090B | 0.46 | >= 0.4f(navSelectedTokensFollowElectronStyleBlocks) |
| smartisan | 0xFF231F1B | 0.42 | — |
| miui-v5 | Color.Black | 0.38 | — |
| claude | 0xFF2D2520 | 0.34 | — |
4.2 对比度下限的自动化守护
ThemeContrastTest.kt 的bodyTextMeetsContrastFloorAcrossStyles对全部 6 套风格 × 明暗两态做校验:
val scrim = colors.overlayScrim() assertTrue(scrim.alpha >= 0.2f, ...) if (dark) assertTrue(scrim.alpha >= 0.34f, ...)同时保证正文对比度textPrimary vs workspace >= 4.5、侧栏>= 4.5、焦点环>= 3。也就是说,遮罩 alpha 的下限是被测试锁定的,防止后续主题改动把遮罩调得过浅影响文字可读性。
这正是设计文档中"ThemeContrastTest校验各风格 scrim alpha、desktopTest 240/240"的证据所指。
五、命令盘:靠上居中的搜索浮层
命令盘是搜索弹层(CommandSearch),实现在 Workbench.kt。
5.1 调用方式
MooOverlay( onDismiss = { container.setSearchOpen(false) }, alignment = Alignment.TopCenter, contentPadding = PaddingValues(top = 96.dp, start = 24.dp, end = 24.dp, bottom = 24.dp) ) { Column( Modifier .width(620.dp) .mooDialogSurface(MooTheme.dimens.commandRadius) .onPreviewKeyEvent { onPaletteKey(it) } ) { // 搜索输入框 + 结果列表 + 关闭按钮 } }- 面板宽620dp,顶部距窗顶96dp(正是文档所说"约 96dp 靠上");
- 圆角取
commandRadius(modern 8dp、hero 16dp 等,见 ThemeContrastTest.kt); - 搜索结果实时根据 query 与当前语言重算:
remember(query, container.settings.value.general.language) { commandSearchEntries(query) { container.t(it) } }; - 选中项索引在结果变化时保持:
LaunchedEffect(results) { selected = nextCommandIndex(selected, results.size, stay = true) }。
5.2 快捷键与焦点管理
命令盘实现了一整套键盘导航(onPaletteKey):
Key.DirectionDown / DirectionUp:移动选中项;Key.Enter:执行选中命令(打开工具或设置分类)并关闭;Key.Escape:关闭命令盘。
焦点管理通过CommandPaletteFocusTarget(Search / Close / Result 三区)与 Tab 迁移函数commandPaletteTabFocusTransition实现,支持 Tab / Shift+Tab 在搜索框、结果列表、关闭按钮间循环,函数commandPaletteTabForwardWalkthrough/commandPaletteTabBackwardWalkthrough还提供了测试友好的步进遍历。
5.3 打开与 Esc 短路
Workbench 顶层BoxWithConstraints挂onPreviewKeyEvent:
- 命中
settings.shortcuts.search快捷键(Electron 侧对应 Cmd/Ctrl+K)时setSearchOpen(true); - 命中
Key.Escape时按优先级短路:searchOpen→groupManagerOpen→showSettings,逐层关闭。
由于快捷键挂在 Workbench 根布局(而非弹层内部),遮罩打开时这些快捷键依然生效,这一点与 DIFF-058 的记录一致。
六、分组管理器:居中的实底面板 + 二级删除遮罩
分组管理器CustomGroupManager实现在 CustomGroupManager.kt,结构是"一个居中 overlay + 一个条件渲染的二级 overlay"。
6.1 主面板
MooOverlay(onDismiss = { container.setGroupManagerOpen(false) }) { Column( Modifier.width(720.dp).heightIn(max = 560.dp).mooDialogSurface().padding(16.dp), ... ) { // 左侧:分组列表(新建/选中/数量) // 右侧:名称编辑 + 工具勾选(ToolRegistry 分组树) // 底部:删除 / 取消 / 保存(prominent,校验通过才可用) } }- 面板宽720dp、高上限560dp,
alignment走默认Alignment.Center(文档"分组管理器居中"); - 左侧分组列表 200dp,右侧工具勾选按
ToolRegistry.groups的既有分组树渲染,每行一个MooSwitch; - 保存校验:
CustomGroupDraft.invalid(groups)返回非空表示非法(名称空白或未勾选任何工具),此时"保存"按钮enabled = false,输入区下方同步显示danger色提示; - 保存时写回设置:
container.updateSettings { current -> current.copy(layout = ...customGroups = CustomGroupDraft.persistable(groups)) }。
6.2 二级删除确认:不关闭管理器
删除确认是第二层MooOverlay,由pendingDelete状态控制:
if (pendingDelete && selected != null) { MooOverlay(onDismiss = { pendingDelete = false }) { Column( Modifier.width(420.dp).mooDialogSurface().padding(16.dp), ... ) { Text(container.t("app.group.manage.deleteConfirm", mapOf("name" to selected.name))) Row { MooButton(container.t("app.group.manage.delete"), danger = true, onClick = { val id = selected.id groups = groups.filterNot { it.id == id } selectedId = next.getOrNull(...)?.id pendingDelete = false }) MooButton(container.t("common.cancel"), onClick = { pendingDelete = false }) } } } }行为要点(与文档完全对应):
- 删除确认浮在管理器之上,形成第二层遮罩;
- 点遮罩或"取消"只把
pendingDelete置回 false,分组管理器保持打开; - 确认删除后移除对应分组,并把选中项迁移到相邻分组,随后关闭确认层。
由于ModalOverlayState.count为 2(两层同时打开),Swing 编辑器在此时也被一并卸下,两层 Compose 遮罩可正常叠放渲染。
七、设计红线与受影响范围
7.1 仍然不是六套 CSS 的逐选择器皮肤
文档明确:"仍非六套 CSS 逐选择器皮肤"。即本方案通过统一 token(overlayScrim()/mooDialogSurface/MooTheme.dimens)覆盖多风格外观,而不是逐选择器复刻 Electron 的command-palette-backdrop/.dialog样式。从 ThemeContrastTest.kt 的modernChromeTokensMatchElectronCss等测试可以看出,token 取值与 Electronglobal.css对齐,但组织方式是 token 化而非选择器移植。
7.2 工具内 Dialog 已由 DIFF-059 统一
命令盘/分组之后,所有工具内弹层统一走 DIFF-059,最终命令盘、分组管理器、工具弹层、历史、Vault Git、退出确认全部收敛到同一套MooOverlay。唯一保持原生的例外是系统FileDialog/JFileChooser/JColorChooser(见 DIFF-059)。
7.3 遗留未测项
设计文档记录的未测范围,在阅读源码时可视为验证边界:
- 产品主窗 Tab / IME 手工交互;
- 托盘 TCC(透明控制)场景;
- 三平台安装包。
相关场景已在 Electron 侧或后续变更中覆盖(如 DIFF-060 记录了关闭 overlay 产品窗截图docs/evidence/2026-09-15-inspector-screencapture/windows/)。
八、可直接复用的实现清单
- 所有应用内弹层一律走
MooOverlay,不再使用 ComposeDialog,避免 macOS 半透明系统窗导致的截图穿透与点击抢层; - 遮罩色从
overlayScrim()取,不要硬编码 alpha;改动主题时保证 alpha 下限与对比度断言(ThemeContrastTest.kt)不回归; - 面板用
mooDialogSurface:workspace实底 + 24dp 阴影 + 1dp 边框,圆角取dialogRadius/commandRadius; - 依赖
ModalOverlayState计数:overlay 打开时EditorHost自动卸下SwingPanel,关闭后恢复同一EditorBuffer,避免 AWT 编辑器遮挡 Compose 遮罩; - 二级确认(如删除)用嵌套
MooOverlay:通过独立状态(如pendingDelete)控制,点外层遮罩只关闭二级层,不关父弹层; - 快捷键挂在 Workbench 根层:
onPreviewKeyEvent里对 Esc 做searchOpen → groupManagerOpen → showSettings短路,保证遮罩打开时快捷键仍然有效; - Esc 与 IME 协同:所有 Esc 处理都先过
blockedByIme(),输入法组合编辑时不误关闭弹层。
九、验证与证据
- 单元/集成测试:
desktopTest在 DIFF-057 落地时为240/240通过(后续 DIFF-059/060 推进到 241/243,见各自设计文档); - 遮罩与 chrome token 断言见 ThemeContrastTest.kt;
- 主窗重拍截图:72-command-palette.png(命令盘)、73-group-manager.png(分组管理器),尺寸 2880×1628,可直接核对弹层在真实主窗内的呈现(遮罩、靠上/居中布局、面板实底)。
延伸阅读:DIFF-058(overlay 与 Swing 编辑器互斥)、DIFF-059(工具内 Dialog 统一)、DIFF-060(退出确认与 chrome token)、architecture.md(SwingPanel 互操作说明)。
- 桌面应用
- 开发工具
【免费下载链接】MooTool
Handy tool set for developers
相关推荐
MooTool next-compose 工具内 Dialog 改造为 MooOverlay 应用内遮罩的完整实现剖析
MooTool next compose 工具内 Dialog 改造为 MooOverlay 应用内遮罩的完整实现剖析 导读 本文基于 MooTool 新一代桌
桌面应用开发工具MooTool Next Compose DIFF-060:退出确认改为应用内遮罩,chrome token 对齐 Electron
MooTool Next Compose DIFF 060:退出确认改为应用内遮罩,chrome token 对齐 Electron 本文基于 MooTool
桌面应用开发工具Vue DevUI Overlay 遮罩层组件实战指南:固定遮罩与弹性跟随定位全解析
Vue DevUI Overlay 遮罩层组件实战指南:固定遮罩与弹性跟随定位全解析 导读 Overlay(遮罩层)是 vue devui https://li
前端UI组件设计系统
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考