diff --git a/examples/skin-examples/README.md b/examples/skin-examples/README.md index 7dd60617..a0544928 100644 --- a/examples/skin-examples/README.md +++ b/examples/skin-examples/README.md @@ -1,90 +1,124 @@ # Metasequoia IME skin example -[![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) -这是 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//` 会被安装,`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.]`;写了 `[toolbar]` 的话,也要有对应的 `[toolbar.]`。 + +完整字段见 [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 - - Star History Chart + + Star History Chart diff --git a/examples/skin-examples/preview.html b/examples/skin-examples/preview.html index 7aa2aa6c..22d5b5d9 100644 --- a/examples/skin-examples/preview.html +++ b/examples/skin-examples/preview.html @@ -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; } diff --git a/examples/skin-examples/schema/README.md b/examples/skin-examples/schema/README.md index 549e596f..cec794d8 100644 --- a/examples/skin-examples/schema/README.md +++ b/examples/skin-examples/schema/README.md @@ -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()`。 diff --git a/examples/skin-examples/skins/niya-demo/assets/background.png b/examples/skin-examples/skins/niya-demo/assets/background.png new file mode 100644 index 00000000..40fb357c Binary files /dev/null and b/examples/skin-examples/skins/niya-demo/assets/background.png differ diff --git a/examples/skin-examples/skins/niya-demo/skin.toml b/examples/skin-examples/skins/niya-demo/skin.toml index bdaa64b1..e054b13f 100644 --- a/examples/skin-examples/skins/niya-demo/skin.toml +++ b/examples/skin-examples/skins/niya-demo/skin.toml @@ -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" diff --git a/examples/skin-examples/skins/niya-demo/toolbar.css b/examples/skin-examples/skins/niya-demo/toolbar.css deleted file mode 100644 index c40e3328..00000000 --- a/examples/skin-examples/skins/niya-demo/toolbar.css +++ /dev/null @@ -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; -} diff --git a/examples/skin-examples/tests/__pycache__/test_manifests.cpython-314.pyc b/examples/skin-examples/tests/__pycache__/test_manifests.cpython-314.pyc new file mode 100644 index 00000000..a28833cf Binary files /dev/null and b/examples/skin-examples/tests/__pycache__/test_manifests.cpython-314.pyc differ diff --git a/examples/skin-examples/tests/install.ps1 b/examples/skin-examples/tests/install.ps1 index 8f0d4a14..187f3b25 100644 --- a/examples/skin-examples/tests/install.ps1 +++ b/examples/skin-examples/tests/install.ps1 @@ -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) { diff --git a/examples/skin-examples/tests/test_manifests.py b/examples/skin-examples/tests/test_manifests.py index 74f1ee6e..b5e7e54e 100644 --- a/examples/skin-examples/tests/test_manifests.py +++ b/examples/skin-examples/tests/test_manifests.py @@ -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__":