feat(examples): 新增 v2 实机演示皮肤「夜樱」 (#1018)

* feat(examples): 新增 v2 实机演示皮肤「夜樱」

基于 canvas 的 schema_version 2 皮肤,用上九宫格面板(四角花纹不随拉伸变形)、面板后的月亮挂件、循环 APNG 花瓣与循环 GIF 星星、content_insets_dip、outer_insets_dip 和 line_gap_dip。左上角不放挂件,免得挡住预编辑文字。图片由 tools/gen_sakura_night.py 程序生成,随本目录以 MIT 授权,重新运行得到同样的文件。

* feat(examples): 夜樱的悬浮工具栏改用候选窗的紫夜配色
This commit is contained in:
Lu Fan(SonnyCalcr) authored and GitHub committed 2026-10-17 04:10:06 +08:00
1 parent 9c45526cd2
commit cbb17c5763
7 files changed
+328 -5

No files matched your search

+27 -5
View File
@@ -38,19 +38,27 @@ examples/skin-examples/
│ │ └─ assets/
│ │ ├─ character.png # 卡片上方的装饰图
│ │ └─ background.png # 卡片背景图
│ └─ canvas-demo/ # schema_version 2 + canvas 基底的全贴图样例
│ ├─ canvas-demo/ # schema_version 2 + canvas 基底的全贴图样例
│ │ ├─ skin.toml
│ │ └─ assets/
│ │ ├─ panel.png # 九宫格面板(2 倍图)
│ │ ├─ leaf.png # 面板下层挂件
│ │ └─ sparkle.apng # 面板上层的循环 APNG 挂件
│ └─ sakura-night/ # v2 实机演示「夜樱」:面板后的月亮、APNG 花瓣、GIF 星星
│ ├─ skin.toml
│ └─ assets/
│ ├─ panel.png # 九宫格面板(2 倍图)
│ ├─ leaf.png # 面板下层挂件
│ └─ sparkle.apng # 面板上层的循环 APNG 挂件
│ ├─ panel.png # 九宫格面板(2 倍图,四角小樱花不随拉伸变形)
│ ├─ moon.png # 面板下层挂件
│ ├─ petals.apng # 面板上层的循环 APNG 挂件
│ └─ stars.gif # 面板上层的循环 GIF 挂件
├─ schema/ # manifest 字段说明
├─ tools/ # 生成样例图片的脚本(需要 Pillow)
├─ tests/ # CI 用的 manifest 与安装脚本测试
├─ preview.html # 本地预览
└─ install.ps1 # 样例安装脚本
```
只有 `skins/<id>/` 会被安装,`preview.html`、`schema/`、`tests/` 和安装脚本都只是制作工具。
只有 `skins/<id>/` 会被安装,`preview.html`、`schema/`、`tools/`、`tests/` 和安装脚本都只是制作工具。
## 样例皮肤能演示什么
@@ -138,6 +146,20 @@ z_index = 2 # z_index < 1 画在面板之下,其余画在
- 动图在系统关闭「动画效果」时只显示第一帧。动图解码后合计不超过 64 MB、最多 1024 帧,超出时只显示第一帧。
- 样例里的三张图都是程序生成的几何图形,随本目录以 MIT 授权,可以直接改用。
`sakura-night`(夜樱)是一个更完整的实机演示,把 v2 的能力都用上了:
- 紫夜玻璃九宫格面板,四角的小樱花落在 24 像素切片内,候选框怎么伸缩都不变形。
- 月亮挂在右上角、`z_index = -1` 画在面板之后,只露出伸出面板的那一截。
- 右侧飘落的花瓣是 24 帧 × 70 ms 的循环 APNG,左下闪烁的星星是 8 帧 × 150 ms 的循环 GIF。
- 用 `content_insets_dip` 让文字避开边框和四角花纹,用 `outer_insets_dip` 给伸出去的挂件留出窗口空间,用 `line_gap_dip` 拉开预编辑行与候选。
- 左上角刻意不放挂件:预编辑文字从那里开始,挂件压上去会挡字。
它的图片都由 `tools/gen_sakura_night.py` 生成(需要 Pillow),重新运行会得到同样的文件:
```powershell
python tools/gen_sakura_night.py skins/sakura-night
```
## 制作约束
- 只用 `skin.toml` 和本地图片,不支持自定义 JavaScript,也不再读取 `cand.css` 或工具栏 CSS。
Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

@@ -0,0 +1,120 @@
# 水杉输入法 schema_version 2 实机演示皮肤「夜樱」。
# 用到 v2 的全部能力:canvas 基底、九宫格面板、面板后/前两层挂件、APNG 与 GIF 动图、内容内边距、外留白、行距。
# 所有图片由 tools/gen_sakura_night.py 程序生成,随本目录以 MIT 授权;都是 2 倍图(像素尺寸是下面 *_dip 的两倍)。
schema_version = 2
id = "sakura-night"
name = "夜樱 · v2 演示"
version = "1.0.0"
author = "Metasequoia IME contributors"
description = "v2 全贴图演示:紫夜玻璃九宫格面板、面板后的月亮、右侧飘落的花瓣(APNG)、左下闪烁的星星(GIF)。"
base = "canvas"
[supports]
layouts = ["horizontal", "vertical"]
themes = ["dark", "light"]
[candidate_window]
min_width_dip = 220
item_corner_radius_dip = 6
line_gap_dip = 6 # 预编辑行与候选之间
# 96×96 的 2 倍面板图:切片 24 像素,边框画 12 DIP。四角的小樱花在切片之内,面板怎么拉伸都不变形。
[candidate_window.border_image]
source = "assets/panel.png"
slice_dip = [24]
width_dip = [12]
fill = true
# 内容离面板外缘的距离;左右多留一点,避开四角的小樱花。
[candidate_window.content_insets_dip]
top = 12
right = 16
bottom = 12
left = 18
# 挂件伸出面板的部分需要的窗口留白。
[candidate_window.outer_insets_dip]
top = 30
right = 36
bottom = 22
left = 12
# 月亮:z_index < 1,画在面板之后,只露出右上角伸出去的那一截。
[[candidate_window.decorations]]
image = "assets/moon.png"
anchor = "top-right"
offset_x_dip = 30
offset_y_dip = -30
width_dip = 72
height_dip = 72
z_index = -1
# 飘落的花瓣:24 帧 × 70 ms 循环的 APNG,沿右侧边框落下。
[[candidate_window.decorations]]
image = "assets/petals.apng"
anchor = "bottom-right"
offset_x_dip = 36
offset_y_dip = 22
width_dip = 48
height_dip = 80
z_index = 3
# 闪烁的星星:8 帧 × 150 ms 循环的 GIF。
[[candidate_window.decorations]]
image = "assets/stars.gif"
anchor = "bottom-left"
offset_x_dip = -12
offset_y_dip = 16
width_dip = 40
height_dip = 20
z_index = 1
# 面板是深色的,深浅两套主题都用浅色文字。
[candidate.dark]
accent = "#ff8fb8"
selected = "rgba(255, 143, 184, 0.22)"
hover = "rgba(255, 143, 184, 0.12)"
text = "#f5ecff"
number = "rgba(245, 236, 255, 0.58)"
translation = "#c9b8ff"
preedit_text = "#ffc4dc"
selected_text = "#ffffff"
selected_bar = "#ffd27a"
[candidate.light]
accent = "#ff8fb8"
selected = "rgba(255, 143, 184, 0.24)"
hover = "rgba(255, 143, 184, 0.14)"
text = "#f5ecff"
number = "rgba(245, 236, 255, 0.60)"
translation = "#c9b8ff"
preedit_text = "#ffc4dc"
selected_text = "#ffffff"
selected_bar = "#ffd27a"
# 悬浮工具栏跟候选窗同一套紫夜配色:底色取面板的紫,边框取面板的粉边,拖动条用主题粉,图标同候选文字。
# 面板在深浅两套主题下都是深色,工具栏也一样。
[toolbar]
corner_radius_dip = 10 # 与面板圆角一致(面板图 20 像素圆角,2 倍图)
[toolbar.dark]
background = "#211838"
border = "rgba(255, 170, 205, 0.55)"
handle = "#ff8fb8"
divider = "rgba(255, 143, 184, 0.28)"
icon = "#f5ecff"
hover = "rgba(255, 143, 184, 0.18)"
[toolbar.light]
background = "#211838"
border = "rgba(255, 170, 205, 0.55)"
handle = "#ff8fb8"
divider = "rgba(255, 143, 184, 0.28)"
icon = "#f5ecff"
hover = "rgba(255, 143, 184, 0.18)"
[license]
code = "MIT"
assets = "MIT"
source = "Procedurally generated by the Metasequoia IME contributors."
@@ -0,0 +1,181 @@
"""Generate the "sakura-night" schema_version 2 demo skin for Metasequoia IME.
All artwork is procedural (Pillow), drawn at 4x and downsampled for anti-aliasing.
Every bitmap is a 2x asset: the manifest's *_dip sizes are half the pixel sizes.
Requires Pillow (pip install pillow). From examples/skin-examples:
python tools/gen_sakura_night.py skins/sakura-night
"""
import math
import random
import sys
from pathlib import Path
from PIL import Image, ImageDraw, ImageFilter
SS = 4 # supersampling factor
def canvas(w, h):
return Image.new("RGBA", (w * SS, h * SS), (0, 0, 0, 0))
def finish(img):
w, h = img.size
return img.resize((w // SS, h // SS), Image.LANCZOS)
def lerp(a, b, t):
return tuple(round(a[i] + (b[i] - a[i]) * t) for i in range(len(a)))
def sakura_flower(draw, cx, cy, r, petal, center, rot=0.0):
"""Five notched petals around a small center, in supersampled coordinates."""
for k in range(5):
ang = rot + k * 2 * math.pi / 5
px = cx + math.cos(ang) * r * 0.55
py = cy + math.sin(ang) * r * 0.55
pr = r * 0.48
draw.ellipse([px - pr, py - pr, px + pr, py + pr], fill=petal)
# notch at the petal tip
nx = cx + math.cos(ang) * r * 1.02
ny = cy + math.sin(ang) * r * 1.02
nr = r * 0.14
draw.ellipse([nx - nr, ny - nr, nx + nr, ny + nr], fill=(0, 0, 0, 0))
cr = r * 0.22
draw.ellipse([cx - cr, cy - cr, cx + cr, cy + cr], fill=center)
def panel(out):
"""96x96 nine-slice panel; slices are 24 px (12 DIP), corner flowers live inside the slices."""
W = H = 96
img = canvas(W, H)
s = SS
# vertical gradient fill clipped to a rounded rectangle
grad = Image.new("RGBA", img.size)
gd = ImageDraw.Draw(grad)
top, bottom = (34, 22, 58, 240), (22, 16, 44, 240)
for y in range(img.size[1]):
gd.line([(0, y), (img.size[0], y)], fill=lerp(top, bottom, y / img.size[1]))
mask = Image.new("L", img.size, 0)
ImageDraw.Draw(mask).rounded_rectangle([0, 0, W * s - 1, H * s - 1], radius=20 * s, fill=255)
img.paste(grad, (0, 0), mask)
d = ImageDraw.Draw(img)
# outer rim and a faint inner highlight
d.rounded_rectangle([1 * s, 1 * s, W * s - 1 * s - 1, H * s - 1 * s - 1], radius=19 * s,
outline=(255, 170, 205, 150), width=2 * s)
d.rounded_rectangle([4 * s, 4 * s, W * s - 4 * s - 1, H * s - 4 * s - 1], radius=16 * s,
outline=(255, 255, 255, 28), width=1 * s)
# corner flowers: entirely within the 24 px corner slices, so they never stretch
gold, pink = (255, 214, 140, 235), (255, 160, 200, 235)
for (cx, cy, col, rot) in [(15, 15, gold, -0.3), (W - 15, 15, pink, 0.2),
(15, H - 15, pink, 0.5), (W - 15, H - 15, gold, 0.9)]:
sakura_flower(d, cx * s, cy * s, 6.5 * s, col, (255, 245, 220, 255), rot)
finish(img).save(out / "panel.png", optimize=True)
def moon(out):
"""144x144 moon with a soft halo; drawn behind the panel (z_index < 1)."""
W = H = 144
s = SS
halo = canvas(W, H)
hd = ImageDraw.Draw(halo)
c = W * s / 2
for i in range(28, 0, -1):
r = (40 + i * 1.1) * s
hd.ellipse([c - r, c - r, c + r, c + r], fill=(255, 210, 120, int(10 + (28 - i) * 2.2)))
body = canvas(W, H)
bd = ImageDraw.Draw(body)
r = 40 * s
bd.ellipse([c - r, c - r, c + r, c + r], fill=(255, 226, 140, 255))
for (dx, dy, rr, a) in [(-12, -8, 8, 70), (10, 12, 11, 60), (14, -15, 5, 70), (-8, 17, 5, 55)]:
bd.ellipse([c + dx * s - rr * s, c + dy * s - rr * s, c + dx * s + rr * s, c + dy * s + rr * s],
fill=(214, 168, 90, a))
img = Image.alpha_composite(halo.filter(ImageFilter.GaussianBlur(6 * s)), body)
finish(img).save(out / "moon.png", optimize=True)
def petal_sprite(size, color):
s = SS
p = Image.new("RGBA", (size * s, size * s), (0, 0, 0, 0))
d = ImageDraw.Draw(p)
d.ellipse([size * 0.18 * s, 0, size * 0.82 * s, size * s - 1], fill=color)
d.ellipse([size * 0.42 * s, -size * 0.12 * s, size * 0.58 * s, size * 0.12 * s], fill=(0, 0, 0, 0))
return p
def petals(out):
"""96x160, 24 frames x 70 ms looping APNG of falling petals (front layer)."""
W, H, N = 96, 160, 24
rnd = random.Random(11)
specs = []
for _ in range(10):
specs.append(dict(x0=rnd.uniform(10, W - 14), y0=rnd.uniform(0, H), size=rnd.randint(12, 18),
sway=rnd.uniform(6, 14), phase=rnd.uniform(0, 2 * math.pi),
spin=rnd.choice([-1, 1]) * rnd.uniform(0.6, 1.4),
color=lerp((255, 190, 215, 230), (255, 140, 185, 230), rnd.random())))
frames = []
for f in range(N):
t = f / N # one full fall per loop keeps the animation seamless
img = canvas(W, H)
for sp in specs:
y = (sp["y0"] + t * (H + 20)) % (H + 20) - 20
x = sp["x0"] + math.sin(sp["phase"] + t * 2 * math.pi) * sp["sway"]
angle = math.degrees(sp["phase"] + sp["spin"] * t * 2 * math.pi)
sprite = petal_sprite(sp["size"], sp["color"]).rotate(angle, resample=Image.BICUBIC, expand=True)
fade = min(1.0, (y + 20) / 30, (H - y) / 30)
if fade <= 0:
continue
if fade < 1:
a = sprite.getchannel("A").point(lambda v: int(v * fade))
sprite.putalpha(a)
img.alpha_composite(sprite, (int(x * SS - sprite.size[0] / 2), int(y * SS - sprite.size[1] / 2)))
frames.append(finish(img))
frames[0].save(out / "petals.apng", format="PNG", save_all=True, append_images=frames[1:], duration=70,
loop=0, disposal=1, blend=0, optimize=True)
def stars(out):
"""80x40, 8 frames x 150 ms looping GIF of twinkling stars (front layer, shows GIF support)."""
W, H, N = 80, 40, 8
rnd = random.Random(3)
pts = [(rnd.uniform(6, W - 6), rnd.uniform(6, H - 6), rnd.uniform(2.5, 5), rnd.uniform(0, 2 * math.pi))
for _ in range(6)]
frames = []
for f in range(N):
img = Image.new("RGBA", (W, H), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
for (x, y, r, ph) in pts:
k = 0.35 + 0.65 * (0.5 + 0.5 * math.sin(ph + f * 2 * math.pi / N))
rr = r * k
# four-point star
d.polygon([(x, y - rr * 2), (x + rr * 0.45, y - rr * 0.45), (x + rr * 2, y), (x + rr * 0.45, y + rr * 0.45),
(x, y + rr * 2), (x - rr * 0.45, y + rr * 0.45), (x - rr * 2, y), (x - rr * 0.45, y - rr * 0.45)],
fill=(255, 226, 150, 255))
# GIF has 1-bit transparency: threshold alpha, then quantize with a reserved transparent index
alpha = img.getchannel("A").point(lambda v: 255 if v >= 128 else 0)
rgb = Image.new("RGB", (W, H), (0, 0, 0))
rgb.paste(img.convert("RGB"), mask=alpha)
p = rgb.quantize(colors=255)
p.paste(255, mask=alpha.point(lambda v: 255 if v == 0 else 0))
frames.append(p)
frames[0].save(out / "stars.gif", save_all=True, append_images=frames[1:], duration=150, loop=0,
transparency=255, disposal=2, optimize=False)
def main():
if len(sys.argv) != 2:
raise SystemExit(__doc__)
out = Path(sys.argv[1]) / "assets"
out.mkdir(parents=True, exist_ok=True)
panel(out)
moon(out)
petals(out)
stars(out)
for f in sorted(out.iterdir()):
print(f"{f.name}\t{f.stat().st_size} bytes")
if __name__ == "__main__":
main()