Tinycast 表情选择器深入解析:可搜索 Emoji 网格的架构、搜索排序与渲染优化
【免费下载链接】tinycastTinycast — a tiny, fully native macOS launcher, hotkeys, and clipboard history.项目地址: https://gitcode.com/GitHub_Trending/ti/tinycast
导读
Tinycast 是一个完全原生的 macOS 启动器、热键与剪贴板历史工具。本文以其 Emoji picker 功能为核心,讲解它在 docs/features/emoji.md 中定义的架构约束、搜索语义、网格渲染策略与置顶/常用/密度管理机制,并结合源码逐层验证。读完本文,你将掌握:Emoji 数据集如何从 Unicode/CLDR 自动生成并保持 Foundation-only 约束;单字与多字查询的模糊匹配与排序层级;约 2000 个格子的大网格如何通过"行级交互 + 行作为滚动目标"两个结构性决策保证流畅;以及 Pinned/Frequently Used 两个持久化存储与网格密度的完整行为约定。
定位:Palette 子屏中的可搜索 Emoji 网格
Emoji picker 是 Tinycast 启动面板(Palette)的一个子屏幕,与 Clipboard、Calculator History 等子屏的进入方式一致。它呈现一个可搜索的 Emoji 网格:空查询时展示按分组组织的分类浏览;输入查询词后,网格切换为带排序的结果列表。整个功能按职责划分为纯数据模型、副作用服务与 SwiftUI 视图三层。
Tinycast 将"目录 + 网格几何"归为纯模型,将"搜索索引 + 持久化存储"归为副作用(effects),理由是这些服务需要访问文件系统与主线程状态。文档在 docs/features/emoji.md 中明确了目录结构:
| 路径 | 职责 |
|---|---|
| Model/EmojiCatalog.swift | 目录模型——分组、名称、关键词 |
| Model/EmojiGridGeometry.swift | 纯网格数学——列数、格子尺寸 |
| Model/EmojiData.generated.swift | 数据集(生成产物) |
| Service/EmojiIndex.swift | 基于目录的搜索索引 |
| Service/FrequentEmojiStore.swift | 持久化的最常使用 Emoji |
| Service/PinnedEmojiStore.swift | 持久化的置顶项(保持用户设置顺序) |
| UI/EmojiGridView.swift | SwiftUI 网格 |
| UI/EmojiScreen.swift、UI/EmojiCoordinator.swift | 调色板屏幕及其动作面 |
两条不可违反的约束(Invariants)
Emoji 模块有两条架构级约束,是理解全部设计的前提:
Model/目录只允许 Foundation。EmojiCatalog、EmojiGridGeometry与生成的数据集会被emoji-test测试套件编译,一旦引入import AppKit就会破坏测试。这也是"目录模型纯逻辑"约束的直接体现:网格几何在 EmojiGridGeometry.swift 中只做基于分节计数的行列数学,不依赖任何 UI 框架。EmojiData.generated.swift由node Scripts/gen-emoji.js生成(要求 Node 18+,因为脚本使用全局fetch),禁止手工编辑。需要更新数据集时,应当重新生成并提交生成物,而不是直接改文件。
数据集:从 Unicode 与 CLDR 自动生成
生成流程与数据源
Scripts/gen-emoji.js 是数据集的唯一合法来源。它从三处上游数据构建记录:
emoji-test.txt(Unicode 官方,https://unicode.org/Public/emoji/latest/emoji-test.txt)——提供fully-qualified状态的 Emoji、其分组与 Emoji 版本号;annotations.json(CLDR 全量注解)——提供关键词(annotations);annotationsDerived.json(CLDR 派生注解)——与全量注解合并,作为关键词回退。
运行方式支持两种:
# 无参数:自动下载三个上游文件并生成 node Scripts/gen-emoji.js # 显式传入本地文件(离线可用) node Scripts/gen-emoji.js emoji-test.txt annotations.json annotationsDerived.json脚本会过滤掉E17.1以上版本的 Emoji(MAX_EMOJI_VERSION = 17.0,注释说明 26.0 的 macOS 只内置到 Emoji 16.0,新版本可能缺字形),随后把 Unicode 分组映射为紧凑的分类码,例如Smileys & Emotion/People & Body→sp、Animals & Nature→an、Flags→fl。
精选符号分类:Unicode 数据覆盖不到的部分
仅靠 Unicode 数据无法覆盖日常高频的文本符号,脚本内置了六张手工策划表(见 Scripts/gen-emoji.js):
| 分类码 | 内容 | 示例 |
|---|---|---|
xa | 方向箭头 | ←→↩⇄⇧ |
xc | 货币符号 | $£€₿¥ |
xm | 数学符号 | +−√∞∑π |
xs | 形状与标点 | ■★✓♥§™ |
xj | 日常 CJK 标点 | ※〃「【〜・ |
xk | 键盘键与常用技术符号 | ⌘⌥⌃⌫⇧⌨⚙ |
注意(Apple logo,U+F8FF)属于 Apple 私有区,不会出现在任何 Unicode 数据中,因此必须手工策划;CJK 标点同样不是 Unicode Emoji,上游数据从不携带。
肤色变体与输出格式
皮肤色调通过扫描emoji-test.txt中带修饰符(U+1F3FB~U+1F3FF)的行来识别"支持肤色的基底":脚本用baseKey去掉色调标量与 VS16(U+FE0F)后判断基底是否存在于色调变体集合中,只有单标量序列(不含逗号)才标记为tone=1。每个记录输出为竖线分隔的五字段行,写入手工头部注释后封装为enum EmojiData { static let raw = """…""" }:
glyph|name|category|tone|keywords生成前脚本会做安全校验:拒绝重复字形、拒绝含"""或反斜杠的危险记录、记录数低于 1500 视为可疑并报错,保证生成物永远可被 EmojiCatalog.parse 解析(parse会丢弃字段数不为 5 或分类码未知的畸形行)。
目录模型中的分类体系
EmojiCatalog.swift 定义了 14 个展示分类(EmojiCategory),每个分类带标题与 SF Symbol 图标,例如smileysAndPeople("sp", "Smileys & People", "face.smiling")、flags("fl", "Flags", "flag")、cjk("xj", "CJK Symbols", "globe")。EmojiCategoryFilter把浏览筛选建模为all/pinned/frequentlyUsed/category(EmojiCategory)四种状态,并统一提供标题与图标,供头部类别菜单、搜索过滤与空查询浏览共用同一份有序模型。
每个条目是 EmojiEntry:glyph既是字形也是 ID,keywords是逗号连接的搜索词(大多数符号为空串)。display(tone:)在支持肤色且配置了色调时,通过 applyTone 去掉 VS16 再追加肤色标量(遵循 UTS #51,修饰符本身就强制 emoji 呈现)来产出实际字形。
搜索:CLDR 词边界的模糊匹配
搜索由 EmojiIndex.search 实现,其排序规则完整继承自 docs/features/emoji.md 的 Search 一节,并与测试行为一一对应:
- 关键词保持 CLDR 短语边界。生成器用逗号连接所有注解并保留每一条,单字查询分别对名称与每个关键词做模糊匹配,因此子序列永远不会跨越两个关键词(例如不会把 "birthday cake" 的匹配拆到两个不相关关键词上)。
- 多字查询的每个词必须命中词首。每个词要么在名称中开启一个新词,要么在某个关键词中开启一个新词(顺序不限)。字面短语 > 仅名称中的词 > 名称+关键词混合的词。
- 排序优先级:完整名称第一,其次完整的前导名称词,再次精确关键词,最后部分前导词。这保证了
birthday查询中 🎂 永远排第一,pray会偏向注解(prayer hands)而不是 "prayer beads"。 - 冒号包裹的查询自动解包。
:+1:会复用 CLDR 的+1注解,无需任何别名表。 - 使用频率只破平局、不升层级。
FrequentEmojiStore.top的前 100 个字形获得 100…1 的加成,且存储的 identity 与 revision 都进入搜索 memo 的键,保证频繁表变化后缓存自动失效。
分值实现细节
textScore 把上述规则落地为具体分值:
- 精确名称匹配直接返回
FuzzyMatch.match的 exact 分值; - 前缀匹配且名称中紧接的字符不是字母/数字(即完整词边界)时,给
leadingWordScore = 95_000减候选长度,使其高于精确关键词、低于精确名称; - 多词查询按
nameWordsScore = 60_000/mixedWordsScore = 50_000分档,子序列匹配只提供微小增量; - 关键词匹配取
min(match.score, leadingWordScore) - keywordPenalty(500),"略低于半档"的设计确保同等质量的名称匹配永远获胜。
得分相同(含平局加成后)的条目按目录顺序(order)稳定排列。空查询返回空结果,搜索结果默认限制 320 条。
搜索记忆化
EmojiIndex用 Memo 做单层查询缓存,键包含query + catalog revision + frequent 存储的 identity 与 revision + limit。目录在 load 时通过Task.detached(priority: .utility)在后台解析原始数据并预计算分节,revision随每次加载递增并写入缓存键,从而在数据更新后天然失效。
渲染:两个承重决策撑起约 2000 个格子
网格最多可实例化约 2000 个 cell,EmojiGridView.swift 用两个结构性决策保证流畅(见 docs/features/emoji.md 的 Rendering 一节):
决策一:交互挂载在行上,绝不挂在 cell 上
单击、双击、右键与 hover 全部一次性挂载在EmojiGridRowView上(EmojiGridView.swift):
- 单击用
SpatialTapGesture选中该格; - 双击用
simultaneousGesture(SpatialTapGesture(count: 2))选中并触发粘贴(作为伴随手势同时注册); - 右键通过
.onRightClick弹出 Actions 菜单; - hover 用
.onContinuousHover计算悬停列,且以palette.hoverHighlightArmed为门控,避免无谓重绘。
如果把这些交互挂到每个 cell,快速滚动会实例化全部格子,而每个 cell 的交互机制(尤其是NSView支撑的右键捕获器)在 2000 规模下大约占用100 MB内存,且惰性容器永远不会释放。行级挂载把开销限制在可见的寥寥几行。因此EmojiCell保持为纯内容视图:无手势、无遮罩、无 hover 跟踪(EmojiGridView.swift)。
Hover 列的计算通过共享的 cell 尺寸与间距把指针 x 映射为列号(column(at:)):落在格子间隙、或落在部分行末行的空槽位时返回 nil。EmojiCell的选中态用 1.6 倍放大的模糊字形(selectedHalo,饱和度 2、模糊半径max(16, size*0.28))垫在正文字形之下,让多彩光晕填满选中格、细外圈仍保持该 emoji 本身的配色。
决策二:行直接挂在外部LazyVStack下
如果把 cell 嵌在LazyVGrid里,未实例化的 cell 无法被滚动到,会破坏按住方向键的键盘滚动。Tinycast 将行作为ScrollViewReader的滚动目标:行 ID 是分节命名空间化的(section.id + "-row-\(row)"),因为常用 Emoji 会同时出现在自己的分类里;任何行即使屏幕外也能被定位。选中进入第一行时恢复到原点(selectedRowID == firstRowID时atOrigin: true),这样分节头也能显示出来。网格复用调色板的滚动条(.thinScrollbar()+.hideNativeScrollers()),本地分节头只追加数量文本而不改动其他屏的共享分节头(参见 docs/ui.md)。
网格几何:跨分节的平铺索引导航
EmojiGridGeometry.swift 把"分节的行列网格"折叠为平铺索引。down(from:)/up(from:)在分节内按列移动,并处理三个边界:本节最后一行未满时先钳制到本行末格、末节末尾保持原位、跨节时用min(local % columns, counts[next] - 1)保留列位置。selectionAfterRemovingPin(at:remainingCount:)用于置顶删除后把选择让位给占据该槽位的邻居。
分类、置顶与密度
头部类别菜单与默认总览
空查询时 EmojiGrid.sections 按过滤器组织分节:all总览依次为Pinned → Frequently Used → 各目录分类;pinned/frequentlyUsed只显示对应分节;category只显示单个分类。搜索时所有过滤器都在结果上做二次过滤(置顶过滤、常用过滤、分类过滤)。头部类别菜单与渲染、搜索共用同一份有序分节模型。
置顶:显式用户数据
置顶字形持久化在 Application Support 下的emoji-pinned.json,顺序是显式用户数据,也会被配置备份携带。其行为约定(对应 docs/features/emoji.md 的 Categories, pins and density 一节,实现在 PinnedEmojiStore.swift 与 EmojiScreen.swift):
- 新置顶追加到末尾且不移动当前选择;
- Actions 菜单或
⌥⌘↑/↓可在 Pinned 内上下移动; - 每个位置都只按"目录能显示的置顶"计数,因此来自更新版本备份、但目录缺少的字形永远不会挤占任何位置;
- 从置顶分节移除选中项时,选择停留在顶替它的邻居上,而不是跟着条目落回目录(
EmojiScreen.togglePin中的分支逻辑)。
EmojiActionsMenu.content(EmojiScreen.swift)按条目类型动态显示动作:Paste(↵)、Copy to Clipboard(⌘↵)、Paste and Keep Window Open(⌥↵,调色板保持打开以便连续输入)、Pin/Unpin(⌘.),置顶项额外获得 Move Up/Down(⌥⌘↑/⌥⌘↓)与 Actual Size(⌘0)、Zoom In(⌘+)、Zoom Out(⌘-)。
常用:有上限的 JSON 计数
FrequentEmojiStore.swift 把使用计数持久化到emoji-frequency.json,上限 300 条,记录以未调肤色的基础字形为键。record(_:)累加计数并更新时间戳,超过上限时按"计数降序、最近使用降序"淘汰最久远的记录;top(_:)默认返回前 16 个(搜索加成用前 100 个),空查询网格每次渲染都会重读top(),但排序结果按 revision 记忆化,每次 tally 只排序一次。备份导入走replace(_:),同样受 300 上限约束。
密度:6~10 列与临时缩放
网格密度为 6~10 列(EmojiGridColumns),默认 8 列。关键设计是偏好与临时缩放分离:AppSettings.emojiGridColumns是全新 picker 的默认密度;缩放(Actions 菜单或快捷键)只写PaletteState.emojiGridColumnsOverride,因此临时缩放不会悄悄改变偏好。⌘0清除 override 回到默认,⌘+减一列(格子变大)、⌘-加一列(格子变小),到达 6 或 10 列边界时applying(_:)返回 nil,对应菜单项自动置灰。
设置面板 EmojiSettingsView.swift 提供两项外观配置:Column Count用 5 个点阵预览图(EmojiGridDots,每个 cell 4 个细分点,保证横竖运行交汇在同一像素)直观展示密度;Emoji Skin Tone用分段控件展示六档肤色(Default/Light/Medium Light/Medium/Medium Dark/Dark,Fitzpatrick 标量 0x1F3FB~0x1F3FF),每档以对应肤色的 👋 手型预览,选择即应用于支持肤色的 emoji。
投递链路
EmojiCoordinator.swift 统一三种投递动作:pasteEmoji/copyEmoji/pasteEmojiKeepingWindowOpen。三者都先frequentEmoji.record(entry.glyph)(按基础字形计数,与肤色无关),再按配置的settings.emojiSkinTone通过entry.display(tone:)应用肤色后投递。粘贴前记录"前一应用"(windowController.previousApp),隐藏调色板后由Paster注入文本;"保持窗口打开"变体则走pasteStringKeepingWindowOpen,配合⌥↵快捷键实现连续输入一排 emoji 而不反复唤起面板。测试套件 Tests/emoji-search-test.swift 与 Tests/emoji-test.swift 覆盖了数据集解析、搜索排序与网格几何的关键行为,可作行为契约参考。
【免费下载链接】tinycastTinycast — a tiny, fully native macOS launcher, hotkeys, and clipboard history.项目地址: https://gitcode.com/GitHub_Trending/ti/tinycast
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考