diff --git a/examples/skin-examples/README.md b/examples/skin-examples/README.md index 5ad2b959..4255ed17 100644 --- a/examples/skin-examples/README.md +++ b/examples/skin-examples/README.md @@ -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//` 会被安装,`preview.html`、`schema/`、`tests/` 和安装脚本都只是制作工具。 +只有 `skins//` 会被安装,`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。 diff --git a/examples/skin-examples/skins/sakura-night/assets/moon.png b/examples/skin-examples/skins/sakura-night/assets/moon.png new file mode 100644 index 00000000..c78b2769 Binary files /dev/null and b/examples/skin-examples/skins/sakura-night/assets/moon.png differ diff --git a/examples/skin-examples/skins/sakura-night/assets/panel.png b/examples/skin-examples/skins/sakura-night/assets/panel.png new file mode 100644 index 00000000..7f7e9955 Binary files /dev/null and b/examples/skin-examples/skins/sakura-night/assets/panel.png differ diff --git a/examples/skin-examples/skins/sakura-night/assets/petals.apng b/examples/skin-examples/skins/sakura-night/assets/petals.apng new file mode 100644 index 00000000..8972f3e2 Binary files /dev/null and b/examples/skin-examples/skins/sakura-night/assets/petals.apng differ diff --git a/examples/skin-examples/skins/sakura-night/assets/stars.gif b/examples/skin-examples/skins/sakura-night/assets/stars.gif new file mode 100644 index 00000000..2e3f0e5a Binary files /dev/null and b/examples/skin-examples/skins/sakura-night/assets/stars.gif differ diff --git a/examples/skin-examples/skins/sakura-night/skin.toml b/examples/skin-examples/skins/sakura-night/skin.toml new file mode 100644 index 00000000..3a045711 --- /dev/null +++ b/examples/skin-examples/skins/sakura-night/skin.toml @@ -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." diff --git a/examples/skin-examples/tools/gen_sakura_night.py b/examples/skin-examples/tools/gen_sakura_night.py new file mode 100644 index 00000000..c9e65b0d --- /dev/null +++ b/examples/skin-examples/tools/gen_sakura_night.py @@ -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()