feat(niya-demo): declare decoration, background and toolbar styles in skin.toml

- Move the decoration image into [candidate_window.decoration] (102x88, right-aligned)
- Add [candidate_window.background] with a translucent texture and 12dip corner radius
- Replace toolbar.css with [toolbar] / [toolbar.dark] / [toolbar.light]
- Bump version to 0.2.0
- Update tests, preview.html and schema docs for the new fields
- Rewrite README
This commit is contained in:
fanlusky committed 2026-09-28 22:13:42 +08:00
1 parent 437b80efda
commit 7bca26d648
9 files changed
+187 -98

No files matched your search

+76 -42
View File
@@ -1,90 +1,124 @@
# Metasequoia IME skin example
<!-- badges:start -->
[![CI](https://img.shields.io/github/actions/workflow/status/metasequoiaime/metasequoia-ime-skin-example/ci.yml?branch=main&label=CI)](https://github.com/metasequoiaime/metasequoia-ime-skin-example/actions/workflows/ci.yml)
[![CodeQL](https://img.shields.io/github/actions/workflow/status/metasequoiaime/metasequoia-ime-skin-example/codeql.yml?branch=main&label=CodeQL)](https://github.com/metasequoiaime/metasequoia-ime-skin-example/actions/workflows/codeql.yml)
[![License](https://img.shields.io/github/license/metasequoiaime/metasequoia-ime-skin-example)](LICENSE)
[![Stars](https://img.shields.io/github/stars/metasequoiaime/metasequoia-ime-skin-example?style=flat)](https://github.com/metasequoiaime/metasequoia-ime-skin-example/stargazers)
[![CI](https://img.shields.io/github/actions/workflow/status/metasequoiaime/msime-skin-example/ci.yml?branch=main&label=CI)](https://github.com/metasequoiaime/msime-skin-example/actions/workflows/ci.yml)
[![CodeQL](https://img.shields.io/github/actions/workflow/status/metasequoiaime/msime-skin-example/codeql.yml?branch=main&label=CodeQL)](https://github.com/metasequoiaime/msime-skin-example/actions/workflows/codeql.yml)
[![License](https://img.shields.io/github/license/metasequoiaime/msime-skin-example)](LICENSE)
[![Stars](https://img.shields.io/github/stars/metasequoiaime/msime-skin-example?style=flat)](https://github.com/metasequoiaime/msime-skin-example/stargazers)
<!-- badges:end -->
这是 Metasequoia IME 外部候选框皮肤的最小示例。候选框由 D2D 绘制,皮肤用 `skin.toml` 声明名称、装饰几何和配色;悬浮工具栏仍可用可选 CSS。
水杉输入法(Metasequoia IME)外部皮肤的官方样例。一个皮肤就是一个文件夹,外观全部写在一份 `skin.toml` 里:候选框的装饰图、背景图、圆角和配色,以及悬浮工具栏的配色。不需要写 CSS,也不会加载任何脚本。
字段说明见 [manifest 文档](schema/README.md),预览入口为 [preview.html](preview.html)。
- 字段说明:[schema/README.md](schema/README.md)
- 本地预览:[preview.html](preview.html)
## 快速开始
1. 用自己的透明 PNG 替换 `skins/niya-demo/assets/character.png`。
2. 打开 `preview.html`,同时检查横向和纵向候选框。
3. 修改 `skins/niya-demo/skin.toml` 中的名称、作者、装饰尺寸和 `[candidate.*]` 颜色。
4. 将整个皮肤文件夹复制到 `%LOCALAPPDATA%\metasequoiaime\skins\`,打开设置的“皮肤”页面(会自动扫描目录),也可以点击“刷新皮肤”或运行 `./install.ps1`。
1. 复制 `skins/niya-demo/`,把文件夹名和 `skin.toml` 里的 `id` 改成同一个新名字。
2. 替换 `assets/` 里的图片:
- `character.png`:卡片上方的人物装饰,建议透明 PNG。
- `background.png`:卡片背景纹理。深浅两套主题共用这张图,建议大部分透明。
3. 修改 `skin.toml` 中的名称、作者、尺寸和配色。
4. 用浏览器打开 `preview.html`,大致确认横向、纵向两种候选框的效果。
5. 把皮肤文件夹复制到 `%LOCALAPPDATA%\metasequoiaime\skins\`,打开设置里的“皮肤”页面(会自动扫描),或者点“刷新皮肤”。
安装脚本会将皮肤复制到:
也可以直接运行安装脚本装上样例皮肤:
```text
%LOCALAPPDATA%\metasequoiaime\skins\niya-demo\
```powershell
./install.ps1 # 复制到 %LOCALAPPDATA%\metasequoiaime\skins\niya-demo\ 并启用
./install.ps1 -NoActivate # 只复制,不改 config.toml
```
默认还会将 `config.toml` 中的 `appearance.candidate_skin` 设置为 `niya-demo`;只想复制、不立即启用时使用 `./install.ps1 -NoActivate`。
启用即把 `config.toml` 中的 `appearance.candidate_skin` 设为 `niya-demo`。装好后重启水杉输入法服务即可生效。
设置页面会读取每个一级子目录中的 `skin.toml`。目录名必须与 manifest 的 `id` 一致;字段不合法或工具栏 CSS 缺失的目录不会进入可选列表,并会显示在页面底部的诊断信息中。
设置页会读取 `skins\` 下每个一级子目录中的 `skin.toml`。目录名与 `id` 不一致或字段不合法时,该皮肤不会出现在可选列表里,原因会显示在页面底部的诊断信息中。
## 仓库与皮肤包结构
## 目录结构
```text
metasequoia-ime-skin-example/
msime-skin-example/
├─ skins/
│ └─ niya-demo/ # 一个皮肤一个文件夹
│ ├─ skin.toml # 名称、继承关系、能力、几何、候选配色
│ ├─ toolbar.css # 可选:悬浮工具栏视觉
│ └─ niya-demo/ # 一个皮肤一个文件夹,文件夹名 = id
│ ├─ skin.toml # 元信息、候选框、工具栏、授权
│ └─ assets/
│ └─ character.png # 本地静态资源
├─ schema/ # 字段说明
├─ preview.html # 仓库级预览入口
└─ install.ps1 # demo 安装工具
│ ├─ character.png # 卡片上方的装饰图
│ └─ background.png # 卡片背景图
├─ schema/ # manifest 字段说明
├─ tests/ # CI 用的 manifest 与安装脚本测试
├─ preview.html # 本地预览
└─ install.ps1 # 样例安装脚本
```
仓库中的 `preview.html`、`schema/` 和安装脚本是制作工具,不会复制进已安装的皮肤目录。
只有 `skins/<id>/` 会被安装,`preview.html`、`schema/`、`tests/` 和安装脚本都只是制作工具。
## 最常修改的参数
## 样例皮肤能演示什么
`niya-demo` 的 `skin.toml` 每一段都带注释,下面是最常改的几处:
```toml
[candidate_window]
min_width_dip = 176
corner_radius_dip = 12 # 0–32,不写则沿用 base
[candidate_window.decoration]
top_inset_dip = 88
width_dip = 136
image = "assets/character.png"
top_inset_dip = 88 # 装饰框高度
width_dip = 102 # 装饰框宽度
align = "right" # left | center | right
[candidate_window.background]
image = "assets/background.png"
fit = "cover" # cover | contain | stretch
opacity = 0.3 # 0–1
[candidate.dark]
accent = "#e08aa8"
accent = "#e08aa8" # 光标与选中胶囊条
selected = "rgba(224, 138, 168, 0.28)"
hover = "rgba(224, 138, 168, 0.16)"
[toolbar]
corner_radius_dip = 8
[toolbar.dark]
background = "#221a1e"
handle = "#e08aa8"
icon = "#f6e4eb"
```
- `top_inset_dip`:候选框上方为图片预留的高度。
- `width_dip`:顶部装饰宽度。
- `min_width_dip`:候选框最小宽度,通常不应小于 `width_dip`。
- `accent` / `selected` / `hover`:光标与胶囊条、高亮行、悬停行。
几个要点:
示例图片高 `118px`,其中下方约 `30px` 被候选框遮住,所以只露出头部。
- 装饰图放在一个 `width_dip × top_inset_dip` 的框里等比缩放,框贴在卡片正上方,不会与卡片重叠。框的比例和原图一致时就不会留白,比如样例原图是 408×353,所以用了 102×88。
- 卡片实际最小宽度取 `min_width_dip` 和装饰图 `width_dip` 中较大的那个。
- 背景图画在底色之上、文字之下,并按圆角裁剪。
- `[toolbar]` 下的所有键都可以省略,没写的沿用 `base` 皮肤;配置对 D2D 和 WebView2 两种工具栏渲染器都生效。
- `[supports].themes` 里声明的每套主题都要有对应的 `[candidate.<theme>]`;写了 `[toolbar]` 的话,也要有对应的 `[toolbar.<theme>]`。
完整字段见 [schema/README.md](schema/README.md)。
## 制作约束
- 皮肤使用 `skin.toml`、可选 `toolbar.css` 和本地图片,不加载自定义 JavaScript。
- 候选框不要再写 `cand.css`。
- ID 只能包含小写字母、数字、点、下划线和连字符,最长 64 个字符。
- 图片建议控制在 500 KB 内。
- 只用 `skin.toml` 和本地图片,不支持自定义 JavaScript,也不再读取 `cand.css` 或工具栏 CSS。
- 图片路径相对于皮肤目录,不能指向目录之外。
- `id` 只能包含小写字母、数字、点、下划线和连字符,最长 64 个字符。
- 单张图片建议不超过 500 KB(CI 会检查)。
## 测试
```powershell
python -m unittest discover -s tests -v # 检查 manifest 与引用的图片
./tests/install.ps1 # 在临时目录里测试安装、启用和重复安装
```
## 授权提示
**本仓库是 MIT 授权的**(见 [LICENSE](LICENSE))。这个仓库存在的意义就是被复制——直接 fork、或者把 `skins/niya-demo/` 整个拷走改成自己的皮肤,都不需要额外授权。选 MIT 而不是产品仓的 GPL-3.0,正是为了让做出来的皮肤可以按作者自己的意愿授权,不被传染。
**本仓库采用 MIT 授权**(见 [LICENSE](LICENSE))。这个仓库就是拿来复制的:直接 fork,或者把 `skins/niya-demo/` 整个拷走改成自己的皮肤,都不需要额外授权。之所以用 MIT 而不是产品仓库的 GPL-3.0,就是为了让你做出来的皮肤可以按自己的意愿授权。
皮肤代码和图片资源可以使用不同的许可。发布前请在 `skin.toml` 的 `[license]` 中分别写明 `code` 与 `assets`。示例里的 `assets` 标为 `UNVERIFIED-DEMO-ONLY`,意思是那些图只用于演示、来源未经核实,**不要直接拿去发布**,换成你自己有权使用的图片。
皮肤代码和图片资源可以用不同的许可,发布前请在 `skin.toml` 的 `[license]` 里分别写明 `code` 和 `assets`。样例中的 `assets` 标为 `UNVERIFIED-DEMO-ONLY`,意思是这些图片只用于演示、来源未经核实,**请不要直接拿去发布**,换成你有权使用的图片。
<!-- star-history:start -->
## Star History
<a href="https://star-history.com/#metasequoiaime/metasequoia-ime-skin-example&Date">
<img src="https://api.star-history.com/svg?repos=metasequoiaime/metasequoia-ime-skin-example&type=Date" alt="Star History Chart" width="600">
<a href="https://star-history.com/#metasequoiaime/msime-skin-example&Date">
<img src="https://api.star-history.com/svg?repos=metasequoiaime/msime-skin-example&type=Date" alt="Star History Chart" width="600">
</a>
<!-- star-history:end -->
+6 -2
View File
@@ -13,10 +13,14 @@
.preview-label { margin-bottom:8px; color:#aaa; }
.containerParent { width:fit-content; padding-top:88px; position:relative; }
.containerParent:not(:empty)::before {
content:""; position:absolute; z-index:0; top:0; right:0; width:136px; height:118px;
content:""; position:absolute; z-index:0; top:0; right:0; width:102px; height:88px;
background: url("./skins/niya-demo/assets/character.png") center / contain no-repeat; pointer-events:none;
}
.container { width:fit-content; padding:2px; border:1.5px solid #9b9b9b2e; border-radius:6px; background:#202020; box-shadow:5px 5px 10px #0008; user-select:none; position:relative; z-index:1; }
.container { width:fit-content; min-width:176px; padding:2px; border:1.5px solid #9b9b9b2e; border-radius:12px; background:#202020; box-shadow:5px 5px 10px #0008; user-select:none; position:relative; z-index:1; overflow:hidden; }
.container::before {
content:""; position:absolute; inset:0; z-index:-1; opacity:.3;
background: url("./skins/niya-demo/assets/background.png") center / cover no-repeat; pointer-events:none;
}
.row { margin-top:2px; padding:0 12px 0 2px; }
.pinyin { display:flex; min-width:140px; }
.cursor { display:inline-block; width:1.5px; height:1.2em; margin-left:1px; background:#e08aa8; vertical-align:middle; }
+41 -6
View File
@@ -2,12 +2,47 @@
每个皮肤目录需要 `skin.toml`,`id` 必须与文件夹名一致。`schema_version` 当前为 `1`。
候选框由 D2D 绘制,颜色写在 `[candidate.dark]` / `[candidate.light]`,不要再提供 `cand.css`。
候选框和悬浮工具栏都由 `skin.toml` 声明,不要再提供 `cand.css` 或工具栏 CSS。所有图片路径都相对于皮肤目录,且不能指向目录之外。
可选字段:
## 顶层字段
- `toolbar_stylesheet`:悬浮工具栏仍是 WebView,可继续用 CSS
- `preview`:设置页装饰图,相对皮肤目录
- `[candidate.*.]`:`accent`、`selected`、`hover`、`surface`、`border`、`text`、`number`、`show_selected_bar`
- 必填:`schema_version`、`id`、`name`、`version`、`author`
- 可选:`description`、`base`(继承的内置皮肤,如 `fluent`)
- `[supports]`:`layouts`(`horizontal` / `vertical`)、`themes`(`dark` / `light`)
- `[license]`:`code`、`assets`
颜色支持 `#rgb` / `#rrggbb` / `#rrggbbaa` 和 `rgb()` / `rgba()`。
## `[candidate_window]`
- `min_width_dip`:卡片最小宽度;实际最小宽度还会被装饰图的 `width_dip` 撑大
- `corner_radius_dip`:卡片圆角,`0`–`32`;不写则沿用 `base`
### `[candidate_window.decoration]`(可选)
卡片上方的装饰图。写了这张表就必须同时给出 `image`、`top_inset_dip`、`width_dip`。
- `image`:图片路径
- `top_inset_dip` / `width_dip`:装饰框的高和宽,框贴在卡片上方,不与卡片重叠;图片在框内等比缩放
- `align`:`left` | `center` | `right`,相对卡片
### `[candidate_window.background]`(可选)
卡片背景图,画在底色之上、文字之下,按圆角裁剪,对深浅两套主题都生效。
- `image`:图片路径
- `fit`:`cover`(铺满裁切)| `contain`(完整显示)| `stretch`(拉伸)
- `opacity`:`0`–`1`
## `[candidate.dark]` / `[candidate.light]`
`accent`、`selected`、`hover`、`surface`、`border`、`text`、`number`、`show_selected_bar`
## `[toolbar]`
悬浮工具栏样式,D2D 与 WebView2 两个渲染器都生效。所有键都可省略,不写的沿用 `base`。
- `corner_radius_dip`:`0`–`32`
- `[toolbar.dark]` / `[toolbar.light]`:`background`、`border`、`handle`(左侧拖动条)、`divider`、`icon`、`hover`
## 颜色格式
支持 `#rgb` / `#rrggbb` / `#rrggbbaa` 和 `rgb()` / `rgba()`。
Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

@@ -1,23 +1,37 @@
# 水杉输入法官方外部皮肤样例:演示候选框的装饰图、背景图、自定义圆角、配色和工具栏样式。
# 所有图片路径都相对于本皮肤目录,且必须留在目录之内。
schema_version = 1
id = "niya-demo"
name = "Niya Demo"
version = "0.1.1"
version = "0.2.0"
author = "Metasequoia IME contributors"
description = "天元突破·妮娅 演示候选框外部图片、右侧对齐、后置层级,以及悬浮工具栏配色。"
description = "天元突破·妮娅:卡片上方右对齐的人物装饰、带星点的背景图、12dip 圆角,以及配套的悬浮工具栏配色。"
base = "fluent"
toolbar_stylesheet = "toolbar.css"
preview = "assets/character.png"
[supports]
layouts = ["horizontal", "vertical"]
themes = ["dark", "light"]
[candidate_window]
# 卡片的最小宽度;实际最小宽度还会被装饰图的 width_dip 撑大。
min_width_dip = 176
# 候选框圆角,0–32。不写则沿用 base 皮肤的圆角。
corner_radius_dip = 12
# 卡片上方的装饰图。不需要装饰就整张表不写;写了就必须给出 image、top_inset_dip 和 width_dip。
# 图片在 width_dip × top_inset_dip 的框里等比缩放,框贴在卡片上方,不与卡片重叠。
[candidate_window.decoration]
image = "assets/character.png"
top_inset_dip = 88
width_dip = 136
width_dip = 102 # 与 408×353 的原图比例一致,框里不留空白
align = "right" # left | center | right,相对卡片
# 卡片背景图,画在底色之上、文字之下,按圆角裁剪。背景图对深浅两套主题都生效,
# 所以这里用的是大部分透明的纹理,而不是一张实心图。
[candidate_window.background]
image = "assets/background.png"
fit = "cover" # cover(铺满裁切)| contain(完整显示)| stretch(拉伸)
opacity = 0.3 # 0–1
[candidate.dark]
accent = "#e08aa8"
@@ -31,6 +45,27 @@ hover = "rgba(196, 92, 122, 0.10)"
surface = "#fff7fa"
border = "rgba(176, 80, 110, 0.22)"
# 悬浮工具栏。键都可省略,不写的沿用 base 皮肤;D2D 与 WebView2 两个渲染器都生效。
# 可用键:background、border、handle(左侧拖动条)、divider、icon、hover。
[toolbar]
corner_radius_dip = 8 # 0–32
[toolbar.dark]
background = "#221a1e"
border = "rgba(224, 138, 168, 0.38)"
handle = "#e08aa8"
divider = "rgba(224, 138, 168, 0.30)"
icon = "#f6e4eb"
hover = "rgba(224, 138, 168, 0.20)"
[toolbar.light]
background = "#fff7fa"
border = "rgba(176, 80, 110, 0.22)"
handle = "#c45c7a"
divider = "rgba(176, 80, 110, 0.22)"
icon = "#3a2a30"
hover = "rgba(196, 92, 122, 0.12)"
[license]
code = "MIT"
assets = "UNVERIFIED-DEMO-ONLY"
@@ -1,36 +0,0 @@
/* Layered on the built-in floating-toolbar HTML (Fluent / default template). */
.status-bar,
.ftb-preview-host .status-bar {
border-color: rgba(224, 138, 168, 0.38);
}
.drag-handle,
.ftb-preview-host .drag-handle {
background: #e08aa8;
}
html[data-candidate-theme="light"] .status-bar,
.theme-light .status-bar,
.ftb-preview-host.theme-light .status-bar {
background-color: #fff7fa;
border-color: rgba(176, 80, 110, 0.22);
box-shadow: 4px 4px 8px rgba(80, 40, 50, 0.12);
}
html[data-candidate-theme="light"] .drag-handle,
.theme-light .drag-handle,
.ftb-preview-host.theme-light .drag-handle {
background: #c45c7a;
}
html[data-candidate-theme="light"] .divider,
.theme-light .divider,
.ftb-preview-host.theme-light .divider {
background-color: rgba(176, 80, 110, 0.22);
}
html[data-candidate-theme="light"] .icon,
.theme-light .icon,
.ftb-preview-host.theme-light .icon {
color: #3a2a30;
}
+1 -1
View File
@@ -8,7 +8,7 @@ try {
$config = Join-Path $imeRoot 'config.toml'
& "$PSScriptRoot/../install.ps1" -NoActivate
if (Test-Path $config) { throw 'NoActivate must not create settings' }
foreach ($file in @('skin.toml', 'toolbar.css', 'assets/character.png')) {
foreach ($file in @('skin.toml', 'assets/character.png', 'assets/background.png')) {
$source = Join-Path "$PSScriptRoot/../skins/niya-demo" $file
$copy = Join-Path $destination $file
if ((Get-FileHash $source).Hash -ne (Get-FileHash $copy).Hash) {
+23 -6
View File
@@ -19,14 +19,31 @@ class ManifestTests(unittest.TestCase):
self.assertRegex(data["id"], r"^[a-z0-9._-]{1,64}$")
for key in ("name", "version", "author"):
self.assertTrue(data[key].strip(), key)
for key in ("toolbar_stylesheet", "preview"):
if key in data:
asset = (path.parent / data[key]).resolve()
self.assertTrue(asset.is_relative_to(path.parent.resolve()), key)
self.assertTrue(asset.is_file(), f"Missing {key}: {asset}")
window = data.get("candidate_window", {})
assets = {key: data.get(key) for key in ("toolbar_stylesheet", "preview")}
for table in ("decoration", "background"):
if table in window:
assets[f"candidate_window.{table}.image"] = window[table]["image"]
if "decoration" in window:
for key in ("top_inset_dip", "width_dip"):
self.assertGreater(window["decoration"][key], 0, key)
for key, rel in assets.items():
if rel is None:
continue
asset = (path.parent / rel).resolve()
self.assertTrue(asset.is_relative_to(path.parent.resolve()), key)
self.assertTrue(asset.is_file(), f"Missing {key}: {asset}")
if asset.suffix.lower() == ".png":
self.assertLessEqual(asset.stat().st_size, 500 * 1024, key)
for scope in ("candidate_window", "toolbar"):
if "corner_radius_dip" in data.get(scope, {}):
self.assertTrue(0 <= data[scope]["corner_radius_dip"] <= 32, scope)
if "background" in window:
self.assertTrue(0 <= window["background"].get("opacity", 1) <= 1)
for theme in data["supports"]["themes"]:
self.assertIn(theme, data["candidate"])
self.assertLessEqual((path.parent / data["preview"]).stat().st_size, 500 * 1024)
if "toolbar" in data:
self.assertIn(theme, data["toolbar"])
if __name__ == "__main__":