edit | blame | history | raw

TagLauncher 当前 Quick Search 规则快照

状态:当前代码实现备份
用途:在后续调整 Quick Search / 快速建议规则前,保存当前行为,便于回看、对照或恢复
生成日期:2026-05-20
主要代码来源:

  • Apptag/QuickSearch.swift
  • Apptag/ContentView.swift
  • Apptag/ApptagApp.swift
  • Apptag/PreferencesView.swift
  • Apptag/AppDefaults.swift
  • Apptag/DataLayer.swift

1. 当前能力总览

当前代码中的 Quick Search 已经包含以下能力:

  • 主界面内按空格打开 Quick Search。
  • 可配置一个全局 Quick Search 快捷键。
  • Quick Search 打开后显示浮层输入框。
  • 查询为空时显示默认建议列表。
  • 输入查询后按应用名称、标签、备注、包标识搜索。
  • 支持精确匹配、前缀匹配、子串匹配、缩写匹配、模糊子序列匹配。
  • 支持中文拼音候选匹配。
  • 支持键盘上下选择、回车启动、Esc 关闭。
  • 启动成功后记录启动次数和最近启动时间。

2. 快捷键规则

2.1 主界面快捷键

当前默认主界面快捷键:

⇧ ⌥ Space
Shift + Option + Space

代码定义:

static var defaultMain: LauncherHotkey {
    LauncherHotkey(keyCode: UInt32(kVK_Space), modifiers: UInt32(shiftKey | optionKey))
}

当前行为:

  • 主界面快捷键始终返回默认值。
  • 设置页中主界面快捷键展示为一项设置,但当前不允许用户编辑。

2.2 主界面内 Quick Search 快捷键

当前主界面内快捷键:

Space

触发条件:

  • 按下的是空格键。
  • 不是按键重复事件。
  • 没有任何修饰键。
  • 主界面窗口当前可见。
  • Quick Search 当前未打开。
  • 不在编辑模式。
  • 不在应用备注编辑状态。
  • 没有 modal interaction。
  • 当前焦点不在文本输入控件中。
  • 当前焦点不在会处理空格的控件中,例如按钮、分段控件、滑块。

代码判断逻辑位于 Apptag/ApptagApp.swiftshouldOpenQuickSearch(for:)

2.3 全局 Quick Search 快捷键

当前默认全局 Quick Search 快捷键:

Fn + Space

代码定义:

static var defaultQuickSearch: LauncherHotkey {
    LauncherHotkey(keyCode: UInt32(kVK_Space), modifiers: UInt32(kEventKeyModifierFnMask))
}

当前行为:

  • 如果用户没有保存过 Quick Search 全局快捷键,默认返回 Fn + Space
  • AppDefaults 迁移逻辑会在没有存储值时写入默认 Fn + Space
  • 设置页允许用户修改全局 Quick Search 快捷键。
  • 设置页允许恢复默认全局 Quick Search 快捷键,也就是恢复为 Fn + Space

2.4 全局快捷键触发行为

全局 Quick Search 快捷键触发后:

  • 如果主界面窗口已经可见,则不执行任何动作。
  • 如果主界面窗口不可见,则打开主界面,并以 Quick Search 模式进入。
  • 这种入口会设置 quickSearchCloseHidesOverlay = true
  • 当用户关闭 Quick Search 时,主界面也会一起隐藏。

代码行为:

private func showQuickSearchFromGlobalHotkey() {
    guard overlayWindow?.isVisible != true else { return }
    showOverlay(initialQuickSearchSource: QuickSearchOpenSource.globalHidden)
}

3. 快捷键设置和冲突处理

3.1 快捷键注册方式

当前使用 Carbon RegisterEventHotKey 注册全局快捷键。

注册成功:

  • 保存快捷键。
  • 状态设置为 Active
  • 清除 pending 快捷键。
  • 清除冲突信息。

注册失败:

  • 保留之前可用的快捷键引用。
  • 保存用户刚录入但注册失败的 pending 快捷键。
  • 状态设置为 Conflict
  • 写入冲突文案。

3.2 当前快捷键状态

当前支持三种状态:

Active
Conflict
Disabled

设置页展示逻辑:

  • 正常时显示当前 active 快捷键。
  • 冲突时显示 pending 快捷键,并标记为 blocked。
  • 如果之前有可用快捷键,冲突说明中会提示“之前可用的快捷键仍保持可用”。

3.3 已知冲突文案

当前代码对以下快捷键有特殊冲突文案:

快捷键 文案含义
⌘ Space Spotlight 冲突
⌥ ⌘ Space Finder 搜索窗口冲突
⌃ Space 上一个输入法冲突
⌃ ⌥ Space 下一个输入法冲突
⌃ ⌘ Space 表情与符号冲突
Fn + Space Fn / Globe / 输入法相关冲突

其他冲突使用 generic 文案:

This shortcut is already used by macOS or another app.
Choose another shortcut, or change the shortcut in the other app and retry.

3.4 快捷键录制规则

录制快捷键时:

  • Esc 取消录制。
  • 纯修饰键不会被接受。
  • 至少要包含一个修饰键。
  • 支持 ShiftOptionControlCommandFn
  • 会忽略单独的修饰键 keyCode。
  • 捕获到合法快捷键后立即尝试注册。

4. Quick Search 打开和关闭规则

4.1 打开规则

Quick Search 打开时:

  • 必须通过 canOpenQuickSearch 判断。
  • 会关闭应用备注气泡。
  • 会清空查询。
  • 会清除手动选择状态。
  • 会清除错误信息。
  • 会增加 focus token,使输入框获得焦点。
  • 会立即刷新搜索结果。
  • 会发送 Quick Search 可见状态通知。

canOpenQuickSearch 条件:

  • 不在编辑模式。
  • 没有未处理的 Uncategorized drop。
  • 没有 Smart Start notice。
  • 没有 drop refresh loading。
  • Quick Search 当前未打开。

4.2 关闭规则

Quick Search 关闭时:

  • 设置 quickSearchVisible = false
  • 清空查询。
  • 清空结果。
  • 清空选中项。
  • 清除手动选择状态。
  • 清除错误信息。
  • 重置 quickSearchCloseHidesOverlay
  • 发送 Quick Search 可见状态通知。

如果 Quick Search 是通过全局快捷键在隐藏主界面时直接打开的:

  • 关闭 Quick Search 时会隐藏主界面。

4.3 Esc 和点击背景

当前行为:

  • Quick Search 输入框内按 Esc 会关闭 Quick Search。
  • 主界面的本地键盘监听中,如果 Quick Search 已打开,按 Esc 会关闭 Quick Search。
  • 点击 Quick Search 背景层会关闭 Quick Search。

5. 搜索索引规则

当前每个应用会生成一个 QuickSearchDocument

文档字段:

app
localizedNames
tagNames
note
bundleIdentifier
lastOpenedAt
openCount

字段来源:

  • app 来自扫描并经过 TagEditor 标注后的应用。
  • localizedNames 从应用 Bundle 中读取本地化名称、Bundle 名称和 Finder 展示名。
  • tagNames 来自应用当前标签。
  • note 优先使用 store.appNotes[path],其次使用 app.note,最后为空字符串。
  • bundleIdentifier 来自应用包标识,没有则为空字符串。
  • lastOpenedAt 来自 store.appLastOpenedAt[path]
  • openCount 来自 store.appOpenCounts[path],没有则为 0。

搜索索引刷新时机:

  • 应用扫描完成后生成。
  • 数据变化后刷新应用并重新生成。
  • 语言变化后刷新应用并重新生成。
  • 如果 Quick Search 正在打开,刷新完成后会重新计算结果。

6. 查询标准化规则

查询标准化:

  • 去掉首尾空白和换行。
  • 合并连续空白为单个空格。
  • 大小写不敏感。
  • 去除音调和变音符号。
  • 转为小写。

如果标准化后的查询为空:

  • 进入空查询建议规则。

如果标准化后的查询不为空:

  • 按空格切分为多个 token。
  • 每个 token 都必须在同一个应用的某个字段中匹配成功。
  • 任何 token 找不到匹配,该应用就不会进入结果。

7. 搜索字段和权重

当前搜索字段:

字段 权重
应用名称 / 本地化名称 100
标签名称 70
应用备注 45
包标识 20

字段优先级也会用于排序:

应用名称优先于标签
标签优先于备注
备注优先于包标识

8. 匹配类型和权重

当前支持的匹配类型:

匹配类型 权重
精确匹配 100
前缀匹配 80
子串匹配 60
缩写匹配 55
模糊子序列匹配 35

8.1 精确匹配

字段标准化文本与 token 完全相等。

8.2 前缀匹配

字段标准化文本以 token 开头。

8.3 子串匹配

字段标准化文本包含 token。

8.4 缩写匹配

仅应用名称字段支持缩写匹配。

缩写生成规则:

  • 单词开头字符进入缩写。
  • 非字母数字字符会开启下一个单词。
  • 小写字符后跟大写字符时,大写字符会作为新词开头。

示例:

Google Chrome -> gc
Final Cut Pro -> fcp

当前缩写匹配要求:

应用名称缩写必须以 token 开头。

8.5 模糊子序列匹配

当 token 长度至少为 2 时,支持非连续字符子序列匹配。

示例:

ps 可以匹配 Photoshop

8.6 位置加分

非精确匹配有位置加分:

positionBoost = max(0, 10 - min(matchStartIndex, 10))

含义:

  • 越靠前命中,分数越高。
  • 最高位置加分为 10。
  • 精确匹配没有位置加分。

单个 token 在单个字段上的分数:

字段权重 + 匹配类型权重 + 位置加分

9. 中文和拼音匹配规则

当前代码对包含汉字的字段生成拼音候选。

拼音候选生成规则:

  1. 对包含汉字的文本执行 kCFStringTransformToLatin
  2. 去掉声调。
  3. 标准化并小写。
  4. 按非字母数字字符切分后重新用空格连接。
  5. 生成三个候选:
  • 带空格拼音。
  • 去掉空格的紧凑拼音。
  • 拼音首字母。

示例:

微信 -> wei xin / weixin / wx

拼音候选参与普通匹配流程:

  • 精确匹配。
  • 前缀匹配。
  • 子串匹配。
  • 模糊子序列匹配。

应用名称、标签、备注都可以生成拼音候选。

包标识不生成拼音候选。

10. 搜索结果打分规则

对于非空查询:

  1. 将查询拆成多个 token。
  2. 每个 token 在所有字段中寻找最高分匹配。
  3. 所有 token 的最高匹配分相加,得到 textScore
  4. 加上行为加分,得到 finalScore

公式:

finalScore = textScore + behaviorBoost

行为加分最高为 20:

behaviorBoost = min(recentBoost + frequencyBoost, 20)

最近启动加分:

最近启动时间 加分
24 小时内 15
7 天内 10
30 天内 5
更早 2
从未启动 0

启动频率加分:

frequencyBoost = min(openCount, 10)

说明:

  • 当前实现只有总启动次数 openCount,没有 7 天 / 30 天分窗口。
  • 行为加分是弱加分,最高只影响 20 分。

11. 非空查询排序规则

搜索结果排序规则:

  1. finalScore 高的排前面。
  2. textScore 高的排前面。
  3. 最好命中的字段优先级更高的排前面。
  4. 最近启动时间更近的排前面。
  5. 启动次数更多的排前面。
  6. 应用名称更短的排前面。
  7. 应用名称按本地化标准排序。

默认返回结果上限:

50

12. 空查询建议规则

当查询为空时,当前代码调用:

emptyQueryResults(documents: documents, limit: min(limit, 6))

也就是说,空查询最多显示 6 个结果。

12.1 候选来源

空查询只使用两类候选:

  1. 有最近启动时间的应用。
  2. 启动次数大于 0 的应用。

不会使用:

  • 当前标签上下文。
  • Smart Start 分类。
  • 冷启动推荐。
  • 应用名称字母兜底。
  • 固定项。
  • 随机应用。

12.2 最近启动列表

最近启动列表规则:

  • 只包含 lastOpenedAt != nil 的应用。
  • 按最近启动时间倒序排序。
  • 最近启动时间相同时,按应用名称本地化标准升序排序。

12.3 高频启动列表

高频启动列表规则:

  • 只包含 openCount > 0 的应用。
  • 按总启动次数倒序排序。
  • 启动次数相同时,按应用名称本地化标准升序排序。

12.4 合并和去重

空查询建议的合并规则:

ordered = recent + frequent

然后按应用 URL 去重:

  • 最近启动列表排在高频启动列表前面。
  • 同一个应用如果同时在 recent 和 frequent 中,只保留 recent 中的位置。
  • 去重后取前 6 个。

12.5 空查询结果分数

空查询结果的 finalScore 使用行为加分:

finalScore = behaviorBoost
textScore = 0
bestFieldRank = Int.max
matchedTagName = nil
noteSnippet = nil

但由于空查询结果已经在 emptyQueryResults 内部按 recent + frequent 排好,后续没有再调用通用 rank 排序。

12.6 当前空查询的实际特点

当前空查询建议可以概括为:

优先最近启动。
最近启动不足时,用总启动次数补充。
最多 6 个。
没有历史时为空。

13. 结果行展示规则

每个结果行展示:

  • 应用图标。
  • 应用名称。
  • 一行详情文本,如果存在。
  • 右侧标签胶囊,如果存在。

13.1 图标

图标尺寸:

46 × 46

圆角:

10

13.2 应用名称

应用名称:

  • 字号 20。
  • semibold。
  • 单行显示。
  • 超出尾部截断。

13.3 详情文本

详情文本优先级:

  1. 如果本次匹配产生了 noteSnippet,显示 noteSnippet
  2. 否则显示完整应用备注的截断版本。
  3. 如果没有备注,则不显示详情文本。

备注截断规则:

  • 如果备注长度大于 72,则取前 69 个字符并加 ...
  • noteSnippet 如果原备注长度大于 80,则取前 77 个字符并加 ...

当前行为说明:

  • 即使空查询建议没有匹配备注,也会显示应用已有备注。
  • 如果应用没有备注,不会用标签替代显示在详情文本位置。

13.4 右侧标签

右侧标签取值:

matchedTagName ?? document.tagNames.first

含义:

  • 如果查询命中了某个标签,显示命中的标签。
  • 否则显示应用的第一个标签。
  • 如果没有标签,则不显示右侧标签。

标签样式:

  • 字号 14。
  • semibold。
  • 胶囊背景。
  • 最大宽度 128。
  • 单行截断。

14. 面板视觉和布局规则

Quick Search 面板:

  • 宽度固定为 760。
  • 背景为 .regularMaterial
  • 圆角为 34。
  • 阴影为黑色 0.18,不透明度,半径 36,y 偏移 18。
  • 边框为主色 0.10,不透明度,线宽 1。

输入区域:

  • 左侧显示放大镜图标。
  • 输入框字体大小为 28。
  • 输入框高度为 44。
  • 横向内边距为 28。
  • 顶部内边距为 22。
  • 底部内边距为 18。

结果行:

  • 行高为 74。
  • 行间距为 2。
  • 列表外部上下内边距为 10。
  • 列表外部左右内边距为 10。

选中态:

  • 深色模式:白色 0.14 不透明度。
  • 浅色模式:黑色 0.075 不透明度。
  • 选中背景圆角为 18。

14.1 面板位置

面板水平居中。

顶部位置:

max(notchHeight + 54, min(112, windowHeight * 0.12))

面板中心点根据估算高度计算:

estimatedPanelHeight = visibleRows * 76 + 122
panelCenterY = panelTopY + estimatedPanelHeight / 2

14.2 最大可见行数

最大可见行数根据窗口高度动态计算:

availableHeight = windowHeight - panelTopY - 84 - 122
maxVisibleRows = max(1, min(8, floor(availableHeight / 76)))

含义:

  • 最多显示 8 行。
  • 至少显示 1 行。
  • 结果数超过可见行数时显示垂直滚动条。

注意:

  • 空查询搜索引擎最多只返回 6 个。
  • 非空查询最多返回 50 个,但界面最多同时显示 8 行。

15. 选择和导航规则

结果刷新后:

  • 如果没有结果,清空选中项,并清除手动选择状态。
  • 如果用户此前手动选择过某项,并且该项仍在新结果中,则保留选择。
  • 否则选择第一项。

键盘行为:

按键 行为
选择上一项
选择下一项
Enter 打开选中应用
Esc 关闭 Quick Search

上下移动规则:

  • 不循环。
  • 到第一项后继续上移仍停留在第一项。
  • 到最后一项后继续下移仍停留在最后一项。

鼠标行为:

  • 悬停某个结果时,将该结果设为选中项。
  • 点击某个结果时,启动该应用。

16. 启动行为和历史记录

16.1 启动成功

从 Quick Search 启动应用成功后:

  • 异步记录启动历史。
  • 关闭 Quick Search。
  • 隐藏主界面。

记录的历史:

appOpenCounts[path] += 1
appLastOpenedAt[path] = Date()

16.2 启动失败

从 Quick Search 启动应用失败后:

  • 不关闭 Quick Search。
  • 不隐藏主界面。
  • 显示错误文案。
  • 重新聚焦输入框。
  • 发送无障碍 announcement。
  • 不记录启动历史。

错误文案 key:

quickSearch.launchFailed

16.3 和 Uncommon 自动规则的关系

记录启动历史时,如果应用的 uncommon 来源是自动标记,并且启动次数达到自动阈值:

  • 从 uncommon 应用列表移除。
  • 移除 uncommon 自动来源。

这部分逻辑位于 TagEditor.recordLauncherOpen(for:)

17. 当前实现没有的规则

以下是当前代码没有实现、但后续新方案可能会加入的规则:

  • 空查询时基于当前标签加权。
  • 空查询时基于 Smart Start 分类冷启动。
  • 空查询时应用名称字母排序兜底。
  • 空查询时固定显示高价值分类应用。
  • 空查询时手动固定建议项。
  • 7 天启动次数和 30 天启动次数分开统计。
  • 空查询建议的明确分数表。
  • 空查询建议列表固定不随窗口高度变化。
  • 主界面全局快捷键可编辑。

18. 恢复当前规则时的关键点

如果后续新规则出问题,需要恢复当前行为,重点恢复以下代码逻辑:

  1. 全局 Quick Search 默认快捷键恢复为 Fn + Space
  2. 空查询逻辑恢复为 emptyQueryResults(documents:limit:)
  • 先取最近启动应用。
  • 再取高频启动应用。
  • 合并后按应用 URL 去重。
  • 最多取 6 个。
  • 没有历史时返回空列表。
  1. 非空查询保留当前字段权重:
  • 名称 100。
  • 标签 70。
  • 备注 45。
  • 包标识 20。
  1. 非空查询保留当前匹配权重:
  • 精确 100。
  • 前缀 80。
  • 子串 60。
  • 缩写 55。
  • 模糊 35。
  1. 行为加分恢复为最高 20:
  • 最近启动加分最高 15。
  • 启动次数加分最高 10。
  • 两者合计封顶 20。
  1. 结果行恢复为:
  • 优先显示 noteSnippet。
  • 否则显示应用备注。
  • 右侧显示命中标签或第一个标签。
  1. Quick Search 面板恢复为:
  • 固定宽度 760。
  • .regularMaterial 背景。
  • 最多可见 8 行。
  • 空查询最多 6 条。