mirror of
https://github.com/qingjian-team/qingjian.git
synced 2026-11-06 16:13:55 +00:00
功能越加越多,原来按早期设置页搭的结构塞不下:几个大页(按键、拼音方案、主题)越写越长,同一功能散在几页。 现在分为开始使用、打字、学语言、个性化、AI 服务、各平台、隐私与帮助、进阶八组,一个功能只在一页讲全,别处一句话加链接。 配置键名、文件路径、文件格式只放进阶组,其他页只写「设置 → 某页 → 某项」,面向普通用户不出现实现词。 设置程序的 ⓘ 链接表、各 README、issue 模板与代码注释里指向旧页面的链接一并改到新地址。
176 lines
21 KiB
Markdown
176 lines
21 KiB
Markdown
# 主题(预览版)
|
||
|
||
候选窗口与 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 画,主题不生效。
|