mirror of
https://github.com/qingjian-team/qingjian.git
synced 2026-11-06 16:13:55 +00:00
候选字号调大后状态条跟着放大,比候选窗口还显眼(用户群反馈)。 状态条改按主题原样画,大小由 [status_bar] size(60%–150%,缺省 100%)单独定,设置的中英文页加滑块。 百分比滑块挪到共用控件,键位提示与状态条共用。
20 KiB
20 KiB
主题(预览版)
候选窗口与 Windows 悬浮状态条由青简渲染器(crates/qingjian-render)按主题画成位图,mac 与 Windows 用同一个渲染器、同一份主题,画出来逐像素一致。
这里记格式全貌与实现,写给维护的人;用户怎么写主题见 docs/user/settings/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)。 表格行高是下限(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](同 CSScubic-bezier)。- 循环动画:
animation: { "duration", "easing", "loop", "keyframes": [{ "at": 0–1, "x", "y", "rotate", "scale", "opacity" }] }。每个属性只在写了它的关键帧之间插值, 开头、结尾没写时按缺省值补(同 CSS 缺 0% / 100%);loop: false播一轮停在最后。变换作用于整棵子树、不影响布局(同 CSStransform)。时钟从窗口出现算起,打字过程中不重置。 - 运行方式:
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);九宫格里原图同色的格子按纯色填。
实现
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 画,主题不生效。