Files
qingjian/docs/design/theme.md
T
yenharvey a20a38acaf docs(user): 用户文档按用户要做的事重新分组
功能越加越多,原来按早期设置页搭的结构塞不下:几个大页(按键、拼音方案、主题)越写越长,同一功能散在几页。
现在分为开始使用、打字、学语言、个性化、AI 服务、各平台、隐私与帮助、进阶八组,一个功能只在一页讲全,别处一句话加链接。
配置键名、文件路径、文件格式只放进阶组,其他页只写「设置 → 某页 → 某项」,面向普通用户不出现实现词。
设置程序的 ⓘ 链接表、各 README、issue 模板与代码注释里指向旧页面的链接一并改到新地址。
2026-10-21 20:06:25 +08:00

176 lines
21 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 主题(预览版)
候选窗口与 Windows 悬浮状态条由青简渲染器(`crates/qingjian-render`)按主题画成位图,mac 与 Windows 用同一个渲染器、同一份主题,画出来逐像素一致。
这里记格式全貌与实现,写给维护的人;用户怎么写主题见 `docs/user/advanced/custom-themes.md`。本文只写已经做到的,做不到的列在末尾「预览版的限制」。
## 主题文件
- 一个主题一个目录:`theme.json`,加上它引用的 `images/…`(PNG、SVG)与 `fonts/…`(TTF / OTF / TTC)。包内路径一律相对 `theme.json`,拒绝 `..` 与绝对路径。
- 内置主题七个,随 crate 编进来(`crates/qingjian-render/themes/<id>/theme.json`):`qingjian` 青简绿(缺省)、`system-blue` 系统蓝、`wechat` 微信绿、`sakura` 樱花、`dafeiyu` 大肥鱼、`miku` 初音未来、`bocchi` 波奇酱;系统蓝、微信绿 `extends` 青简绿、只改颜色变量,后四个整套重写了窗口、组件与状态条(只有浅色)。
内置主题的图片用 `include_bytes!` 编进程序(`theme/mod.rs` 的 `BUILTINS` 里每个主题一张「路径 → 内容」表,与用户主题走同一套解码),不带字体文件、只用系统字体;测试检查内置主题用到的图片都编进去了。用户主题 `extends` 内置主题时,主题目录里没有的图片到那个内置主题编进程序的文件里找(只看顶层 `extends` 那一层)。
- 用户主题放在 `<用户数据目录>/themes/<id>/`,由 `ThemeLibrary` 与内置主题合成一个列表(内置在前、用户按 id 排)。目录名必须等于 `meta.id`,不能与内置主题重名,
不能是 `system` / `light` / `dark`;读不进来的跳过并记警告。设置界面(mac 偏好设置、Windows 设置程序「候选窗口」页)按显示名列出,写回 `[general] theme` 的 id。
### 为什么用 JSON
主题是嵌套很深的图层树:TOML 写深层嵌套要么是一长串表头、要么是不能换行的内联表;YAML 缩进错一格结构就变,还有隐式类型转换;自定义格式的解析器、高亮、补全都得自己做。
设置是平铺的键值,仍用 TOML(`config.toml`)。
手写时可以加注释(`//`、`/* */`)与尾逗号(JSONC,同 VS Code 的 `settings.json`):`theme/jsonc.rs` 在解析前把它们换成空格、保留换行,报错的行号、列号对得上原文件。
## 格式(schema 1)
内置主题全部用下面的写法写成,与改造前写死的排版逐像素一致(快照测试)。
顶层:
| 键 | 内容 |
|---|---|
| `extends` | 以某个内置主题为底:对象逐键合并,数组与标量整个替换;目前只能继承内置主题。不写就以青简绿为底,继承链上每一层都一样(内置主题也是),只有青简绿自己是最底层 |
| `schema` | 格式版本,整数,只在不兼容改动时加一;比渲染器认得的新时记警告、按认得的部分画 |
| `meta` | `id`、`name`、`author`、`license`;`appearance: "light" \| "dark"` 锁定外观(见下) |
| `variables` | 颜色变量,`"#rrggbb[aa]"` 或 `{ "light", "dark" }`,节点里用 `@名字` 引用 |
| `text` | `gamma`(可分深浅)、`family`(缺省字族)、`styles`(命名样式:`size`、`line_height`、`weight`、`family`、`style`) |
| `fonts` | 随主题带的字体文件 `[{ "file": "fonts/x.ttf" }]` |
| `components` | 可复用的节点,`use` / `repeat` 按名字引用 |
| `windows` | `vertical` / `horizontal` 两个根节点,按用户选的排布取一个 |
| `status` | 悬浮状态条(只有 Windows 有):字体、内边距、圆角、各处颜色、`effects`,排法固定;写了 `root` 就按节点树画(见「状态条」) |
| `keyboard` | 双拼键位提示的键盘:键名与小字的字体、键的宽高与间距、圆角、各状态(平常 / 刚敲下 / 能接 / 拼不出)的填充与文字颜色、`effects`,排法固定(三排错位);整块跟着候选字比例缩放,透明度由壳设在窗口上 |
节点:
| `type` | 属性 |
|---|---|
| `frame` | `direction`(row / column)、`fill`、`border`、`radius`、`children`;带 `table: { row_height }` 时排成表格 |
| `text` | `bind` 或 `text`(固定文字)、`font`、`color`、`stroke` |
| `icon` | `icon`(cloud / gear)、`size`、`color`;盒子缺省与图标同大,图标垂直居中 |
| `preedit` | `font`、`typed` / `rest` / `struck` 三种拼音颜色、`caret: { width, color }`、`stroke` |
| `annotation` | `bind`、`font`、`gloss` / `fresh` / `faint` 三种颜色(`pos`、`separator` 可单独写,缺省同 `faint`)、`tones`(只画哪几种片段)、`senses`(只画前几个义项)、`stroke` |
| `use` | `component`:引用组件,这里写的盒子属性盖过组件根节点的 |
| `repeat` | `bind`(`candidates`)、`component`:每项候选实例化一份 |
所有节点都可写:`when`(显示条件)、`id` + `transition`(过渡)、`animation`(循环动画)、`margin` / `padding`(一个数或 `[上, 右, 下, 左]`,外边距可写 `"auto"`)、`gap`、
`width` / `height` / `min_width`、`position: "absolute"` + `inset`、`align_self`(start / end / center / stretch)、`span: "row"`(表格里横跨整行)、`opacity`(整棵子树)、`effects`。长度单位是点。
### 数据
- **条件**:`when` 是数据字段名,`!` 取反,`a|b` 任一成立。候选项里:`highlighted`、`cloud`、`annotation`、`first`、`last`;
整帧:`preedit`、`trailing`、`trailing.cloud`、`page`、`candidates`、`annotations`(任一候选有译文)、`highlighted`、`highlighted.annotation`;
输入状态:`mode.english`、`mode.traditional`、`mode.full_width`、`mode.scheme`(有方案名)。
- **绑定**:文字 `index`、`text`(候选项)、`page`、`trailing.text`、`mode.scheme`(方案显示名);译文 `annotation`(候选项)、`highlighted.annotation`;列表 `candidates`。
- **条件颜色**:`{ "if": "highlighted", "then": "@hl_text", "else": "@text" }`,条件写法同 `when`。系统蓝、微信绿的高亮白字靠它;青简绿里 `hl_*` 与普通颜色同值。
- **译文片段**:壳把译文拼成一串带种类(`Tone`)的片段:`gloss` 译文、`fresh` 生词译文、`pos` 词性、`separator` 义项间的 ` · `、`faint` 假名注音、`code` 辅码。
`tones` 按种类挑、`senses` 按分隔数截前几个义项,词性与译文分两行排就是两个节点;挑完是空的也留着节点,主题给它定高就照样占行。系统绘制的旧路径把 `pos` / `separator` 画成与 `faint` 同色。
- **输入状态**(`Frame::mode`):渲染器只给事实(三个布尔与方案名),显示什么字由主题用 `when` 分支写,渲染器里没有「中 / 英」这类文案。
mac 壳组装候选帧时填(中英看 Caps Lock,与菜单栏一致;简繁、标点、方案看配置;英文模式标点算半角);
Windows 由 Router 现算(与状态条同一来源),经 `CandidateSink::show` 交给 UI 线程,不进 Server ↔ DLL 协议。方案名 `qingjian_platform::scheme_name`:同状态条的 `scheme_label`,单开全拼时也写「全拼」。
- **设置里的字号**:`[general] candidate_font_size` / `annotation_font_size`(0 用主题的,不限范围)由壳经 `Theme::with_text_sizes` 盖到主题上:
`candidate` 与 `annotation` 换成设置的字号,行高同比例;`index`、`preedit` 与表格 `row_height` 按候选字的比例缩放(`theme/text_sizes.rs`)。
节点里在样式上写死的字号(`{ "base": "candidate", "size": 19 }`,手机候选条这样写)也按那个样式的比例缩放。
表格行高是下限(`minmax(row_height, auto)`),内容更高时撑开。
状态条不跟设置的字号:`render_status` 去掉设置的字号按主题原样画,大小由壳按 `[status_bar] size` 乘进整条的倍数(文字、图标、边距、点击边界一起缩放)。
- **状态条**:`status.root` 是一棵与候选窗口同写法的节点树,`repeat` 绑定 `cells` 展开格子(Windows 现在是模式、标点、齿轮三格)。
格子里的条件 `emphasized`(当前模式、生效的全角标点)、`gear`、`first`、`last`,文字绑定 `text`;整条能用 `mode.*`(`Renderer::render_status` 收一份 `Mode`,Windows 的 `StatusView` 带着,与候选窗口同一个 `Router::input_mode`)。
点击按格:每格第一个产出的节点的右边界(根坐标),没画出来的格同前一格,最后一格延到内容右边;格子之间的装饰算前一格(`renderer/status/tree.rs`)。
没写 `root` 走原来的固定排法(内置主题都是),Windows 的状态条快照不变。
- **表格**:`repeat` 出来的每份组件是一行,组件根节点的子节点依次是各列,每列取各行最宽,行高至少 `row_height`;末尾自动补一列吃掉剩余宽度,
`span: "row"` 的格子(高亮条)因此能横跨整个表格。列间距用各格的外边距写。竖排三列对齐就靠它。
一行里的各格(不含 `span`)按各自第一段文字的基线对齐:布局后把基线偏高的格子往下补上边距再排一遍,差不到一像素的不动(`scene/baseline.rs`);
所以格子的上边距只能把它往下压,不能让它高过同行的基线。
### 填充与效果
- **填充**:颜色(含条件颜色);`{ "linear": 角度, "stops": [...] }`(CSS 角度);`{ "radial": [cx, cy], "stops": [...] }`(圆心按比例、半径到最远角);
色标是颜色(均分)或 `[颜色, 位置]`;`{ "image": "images/x.png", "slice": [上, 右, 下, 左], "scale": 2 }`(slice 为图片像素的九宫格切边、不写就拉伸,scale 为一个点对几个图片像素)。
`border: { width, color }` 画在内侧。图片随主题目录加载(`Theme::from_dir`),PNG 边长 ≤ 4096。
- **SVG**:路径以 `.svg` 结尾的图片用 resvg 解析(`theme/svg_image.rs`),单位当作点、不管 `scale`,`slice` 也按 SVG 单位写。加载时解析一次(文件 ≤ 4 MB、原始尺寸 ≤ 4096),
画的时候才栅格:拉伸按盒子的像素尺寸直接栅格,九宫格按原始尺寸 × 倍数栅格再切,每张最多缓存 8 种尺寸,之后与 PNG 同一条贴图路径(图片框的离屏缓存也一样)。
不读外部文件(`<image href>` 的字符串解析器换成一律返回空,否则 usvg 会按路径读盘),不开 `raster-images`(内嵌位图不解码)、不开 `text`(`<text>` 不画、不带字体库)。
- **效果**:`effects: [{ "type": "drop-shadow" | "inner-shadow", "x", "y", "blur", "spread", "color" }]`,按写的顺序画。
形状取节点自己画出来的 alpha(圆角框、九宫格图片的透明边、文字),不含子节点;自己不画东西的容器(没填充的框、译文、拼音行)取子节点;`spread` 只对框生效。
投影垫在节点底下、半透明填充会透出来;内阴影压在填充上、子节点下。
窗口阴影就是根节点的投影:渲染器按投影伸出的距离在位图四周留边,壳按内容区对齐光标,mac 面板关掉系统阴影(系统绘制退路仍用系统阴影)。状态条的阴影写在 `status.effects`。
- **伸出窗口的装饰**:负 `inset` 的绝对定位节点(`"inset": [-24, -24, "auto", "auto"]`)。布局后把所有自己画东西的节点(连同投影、描边)并成画出范围,位图按它开,
根节点的盒子是内容区(`Rendered::content_*`);壳照旧按内容区对齐光标、夹进屏幕,装饰可以伸到屏幕外。
鼠标整个穿透:mac 面板本来就 `ignoresMouseEvents`,Windows 候选窗加 `WS_EX_TRANSPARENT`。
- **锁定外观**:`meta.appearance` 写了就不跟随外观设置切换;用浅色图片做底的主题要锁浅色。
### 文字
- **字重**:`weight` 写 100–900 或 `thin` / `extralight` / `light` / `regular` / `medium` / `semibold` / `bold` / `extrabold` / `black`。
mac 的 SF 是可变字体、苹方是多字重集合;Windows 另加载 Segoe UI 与雅黑 / 正黑的粗细体文件,缺的字重挑最近的一档。
- **行内改样式**:节点的 `font` 可以写 `{ "base": "index", "size": 12, "weight": "semibold", "style": "italic" }` 在某个样式上改几项,只改字号时行高等比缩放;字族不能在这里改,要换字族就定义命名样式。
- **字族与斜体**:`text.family` 是缺省回退链,样式上 `family`(字符串或数组)覆盖它,`style: "italic"` 斜体;`system` 是界面字体(用户选的字体,没选就是平台界面字体)。
加载主题时把缺省链与各样式的链(按样式名排)去重成字族表(`theme/font/families.rs`),`FontSpec` / `TextStyle` 里只存序号,仍可按值复制。
渲染器换主题时给每条链挑第一个字体库里有的字族,一个都没有用界面字体;字体里缺的字由 cosmic-text 按 locale 回退。没有斜体面的字体倾斜 14° 合成(汉字也是)。
- **字体加载**:字体库不扫系统字体目录(fontdb 全扫几百毫秒、几十 MB),所以主题写到的系统字族由壳查文件:`Renderer::load_theme_fonts(theme, family_files)`,
mac 走 CoreText、Windows 走 DirectWrite(与用户在设置里选字体同一个查法),字体库里已有的字族不再查。随主题带的字体(单个 ≤ 32 MB)在换主题时先加载。
同一个文件只加载一次,不卸载。
- **描边**:`text` / `annotation` / `preedit` 上写 `stroke: { width, color }`(颜色可写条件)。cosmic-text 取字形轮廓(`SwashCache::get_outline_commands`)拼成一条路径,
圆角接头按两倍宽描一次、字形压在上面,露出的是向外的宽度,不占排版宽度;位图 emoji 没有轮廓不描。文字阴影、发光就是文字节点上的 `effects`(形状含描边)。
### 动画
- **过渡按 `id` 配对**(同 Figma 的 Smart Animate 按图层名配对):新一帧里带 `id` 且写了 `transition` 的节点,在上一帧找同 `id` 的节点,从它的位置、大小、不透明度插值到新的;颜色、字号不过渡。
`repeat` 展开出同一个 `id` 的多份时按出现顺序加序号(`word#0`、`word#1`)各自配对;只出现一次的不加,所以只在高亮行里出现的高亮条换行后仍配得上。上一帧没有同 `id` 的就直接出现。
- `transition: { "duration": 毫秒, "easing": … }`:`easing` 为 `linear`、`ease`、`ease-in`、`ease-out`、`ease-in-out`,或 `[x1, y1, x2, y2]`(同 CSS `cubic-bezier`)。
- **循环动画**:`animation: { "duration", "easing", "loop", "keyframes": [{ "at": 0–1, "x", "y", "rotate", "scale", "opacity" }] }`。每个属性只在写了它的关键帧之间插值,
开头、结尾没写时按缺省值补(同 CSS 缺 0% / 100%);`loop: false` 播一轮停在最后。变换作用于整棵子树、不影响布局(同 CSS `transform`)。时钟从窗口出现算起,打字过程中不重置。
- **运行方式**:`Renderer` 留住上一帧的场景树与布局;`Rendered::next_frame` 告诉壳多久后要下一帧(没有动画为 `None`),壳到点调 `tick`,只按插值重画、不重建树、不重排版。
新数据到了,新内容立刻画出,只有带过渡的节点从旧位置出发,按键到候选出现的延迟不变。画出范围按动画能到的最远处算(过渡取起止的并,循环取最大平移、旋转后的外接圆),动画中途位图不变大小。
过渡 60 帧、循环动画上限 30 帧;窗口隐藏时停下,没有动画时不开定时器。系统开了「减少动态效果」或设置里关了 `animations`(`Theme::with_animations`)时过渡直接跳到终点、循环动画停在第一帧。
壳:mac 用主线程 `NSTimer`,Windows 在 UI 线程 `SetTimer`。
- **局部重画**:循环动画用脏矩形(`renderer/partial.rs`):动画节点把树序切成几段,不动的各段画进图层缓存,每帧只在动画节点上一帧与这一帧占的区域里按原先后叠「段、动画节点、段……」,
与整张重画只差半透明叠加的取整(测得最多 3 / 255)。动画节点的上级有半透明容器、拿子节点当阴影形状的容器或别的动画节点,或同时有过渡在播时,退回整张重画。
效果遮罩与图片填充的框按「节点 + 位置大小 + 画布大小」缓存(`scene/cache_key.rs`);九宫格里原图同色的格子按纯色填。
### 手机键盘
手机候选条用窗口模板 `windows.strip`(青简绿里写、其余主题经 `extends` 继承),键盘本身不由主题文件描述:配色从颜色变量推出来(`theme/keyboard.rs` 的 `KeyboardColors`),新主题手机上自动有一套,Android 壳只照着用、不另写颜色。
- 键面取 `surface`(叠在白 / 黑上成不透明);按下与首选高亮取 `accent` 叠在键面上;文字取 `text` / `gloss` / `faint` / `fresh` / `cloud`。缺的变量用青简绿的值。
- 深浅按键面亮度判断(不看外观设置),锁定浅色的主题在深色模式下仍是浅色键盘。
- 底板与功能键:浅色是键面掺一点 `accent` 的色相再压暗(底板 8% 色相、6% 黑,功能键 12%、13%);深色是键面提亮 8% 当键面,底板压暗 35%,功能键只掺色相。
- 回车键(品牌色):`accent` 去掉不透明度后保持色相与饱和度,按 HSL 亮度调暗到白字对比度 ≥ 3.5。
- 只换整体样子的角色主题(樱花、大肥鱼、初音未来、波奇酱)高亮写死在组件里,为了候选条与键盘也是这个主题的颜色,各自补了 `surface` 与 `accent` 变量(桌面窗口不用这两个变量,画法不变)。
## 实现
```
theme.json + Frame 数据
→ 实例化(renderer/build):按 when 取舍、按 bind 填数据,use / repeat 展开组件
→ 布局:Taffy(flexbox)算出每个节点的矩形,表格自己按列取最宽
→ 绘制:tiny-skia 按树序画,阴影在离屏遮罩上做;文字 cosmic-text 整形 + swash 栅格
→ 位图 + 内容区 + 各候选 / 状态条格子的命中区域
```
- 内部全用像素:主题里的点数进来先乘缩放倍数。文字节点的盒子顶边就是行框顶边,字形在行高里垂直居中。
- **容错**:加载时检查颜色变量、文字样式、组件引用,找不到的记警告;渲染时颜色退回透明、样式退回 16/19、组件不画。不认识的键直接忽略。
读不进来的图片不画,读不进来的字体按回退链往后找。正在用的主题整个读不进来时用青简绿。
- **热加载**:目录戳(各 `theme.json` 的修改时间与大小)变了就重读。mac 在激活期间每秒的配置检查里 `refresh()`;Windows Server 在热加载轮询里比戳,变了把设置重发给 UI 线程重读。保存后约一秒生效。
- **配置键**:`[general] appearance` 是外观(system / light / dark),`[general] theme` 是主题 id(缺省 `qingjian`)。2026-09-18 之前外观写在 `theme` 里:
没写 `appearance` 且 `theme` 是这三个词之一时按外观读、主题用内置的,所以这三个词不能当主题 id。读取一律走 `GeneralConfig::appearance()` / `theme_id()`。
Server ↔ DLL 帧协议里的字段在 Rust 里改名为 `appearance`,线上仍叫 `theme`,旧 DLL 照常能解析,协议版本不变。
- **性能**(2026-09-18,mac、release、2 倍屏、字形缓存已热):内置主题新帧约 6.6 ms;能力展示主题(九宫格 + 十几处阴影)新帧约 15.5 ms、过渡帧约 7.3 ms、
循环动画帧约 0.2 ms(第一帧切段约 7 ms)。
## 测试与预览
- 快照测试 `crates/qingjian-render/tests/snapshots.rs`:样例帧(`tests/scenes/`)按浅 / 深色画成位图,与 `tests/snapshots/<os>/` 的基准逐像素比;字体环境与基准不一致时跳过。
- 能力展示主题 `crates/qingjian-render/tests/themes/showcase/`(程序生成的图片)覆盖九宫格、阴影、伸出装饰、过渡与循环动画。
- 主题功能 `tests/theme/`:
- `fonts.rs`:系统字族经壳查文件加载、回退链、随主题带的字体;
- `status.rs`:状态条节点树按 `cells` 展开、点击边界、`mode.*` 分支;
- `svg.rs`:九宫格四角按倍数、超出主题目录的路径不读;`theme/svg_image.rs` 里测了不读外部文件。
- 离线预览:`cargo run --release -p qingjian-render --example preview -- --theme <主题目录>`,把样例帧画成 PNG;主题写的系统字族在 `--font-dir` 里按名字找。
## 预览版的限制
- 状态条的节点树画法不播动画;mac 没有悬浮状态条(菜单栏图标),主题的状态条只在 Windows 上显示。
- 翻页只能显示页码文字;画出来的翻页按钮点不了。
- 图片是 PNG 与 SVG;SVG 里的文字、内嵌位图不画;没有 APNG 帧动画。
- 没有窗口出现 / 消失动画、混合模式、图层模糊、渐变字、变体覆盖(用 `when` 分支与条件颜色代替)。
- 背景毛玻璃做不到:自绘位图读不到窗口后面的内容。
- 主题只能以目录形式安装,`extends` 只能继承内置主题。
- Linux 上候选窗由 Fcitx5 / IBus 画,主题不生效。