* feat(server,ui): 悬浮工具栏图片模式与装饰图叠加候选词 在 #1046 的基础上按最新 develop 重做。 悬浮工具栏 [toolbar] mode = "image":一整张背景图,按钮按 [toolbar.buttons.<按钮>] 的 x_dip / y_dip / width_dip / height_dip 摆放, 可按状态换图([images]),没图时画矢量图标。窗口按背景图的透明区域裁成 异形,透明处点得穿;图上除按钮外都能拖动。v2 皮肤的动图会播放,工具栏 隐藏或淡出后停帧。 候选窗装饰图可用 candidate_overlay_* 叠加一个候选词,太宽时缩字号; 设置页的皮肤预览一并显示。 ui 新增 OverlayPanel(绝对定位)与 ImageMask(按 alpha 生成区域)。 * fix(ui): 动图工具栏的窗口形状取所有帧的并集 窗口区域连画面一起裁,只按首帧裁形状时,动画里伸出首帧轮廓的部分 (抬头、飞出的纸)会被裁掉。改为把所有帧的 alpha 合起来再生成区域。 * fix(server): 工具栏按钮框始终算在异形窗口里 窗口形状只取背景图不透明的部分时,摆在图片透明处的按钮会被一起裁掉、 点不到。形状改为背景图轮廓并上每个按钮的框,按钮可以放在人物旁边的空白处, 不必在背景图里垫底。 * feat(server): 工具栏按钮可单独设内置图标的字号 [toolbar.buttons.<按钮>] 新增 icon_size_dip,内置 Segoe Fluent Icons 图标的 字号不再只能跟着按钮框走:按钮框铺满背景图里的圆底、图标单独调大小。 不写时仍是按钮框短边的 0.82 倍。 * feat(server): 设置页按图片模式预览悬浮工具栏 皮肤页的卡片与悬浮工具栏页的预览原来只会画矩形条。皮肤是图片模式时, 改按工具栏的画法预览:背景图(v2 皮肤的动图会播放)、按皮肤坐标摆放的 按钮、按钮贴图或内置图标(含 icon_size_dip),设置页关掉的按钮不画, 缩放与图标尺寸设置一并生效。 --------- Co-authored-by: fanlusky <1730976608@qq.com>
176 lines
11 KiB
Python
176 lines
11 KiB
Python
"""Validate the documented skin package contract and all shipped asset references."""
|
|
from pathlib import Path
|
|
import os
|
|
import re
|
|
import tomllib
|
|
import unittest
|
|
|
|
ROOT = Path(__file__).resolve().parents[1]
|
|
# MSIME_SKINS_DIR points the same checks at another skin collection, e.g. the repository's skins/.
|
|
SKINS_DIR = Path(os.environ.get("MSIME_SKINS_DIR", ROOT / "skins")).resolve()
|
|
BUILTIN_SKINS = ("fluent", "wechat", "wechat_scroll", "graphite", "willow_green", "autumn_osmanthus", "microsoft")
|
|
# Mirrors the Server catalog: community bases shared with msime, adapted onto fluent at load time.
|
|
SHARED_BASES = ("system", "shuishan", "light", "paper", "night", "ink")
|
|
# The full-texture base: only a base for schema_version 2 skins, never a skin of its own.
|
|
CANVAS_BASE = "canvas"
|
|
# Images v2 tables and the toolbar's image mode may reference (border_image.source, decorations.image,
|
|
# toolbar.image, toolbar.buttons.*).
|
|
V2_IMAGE_SUFFIXES = (".png", ".apng", ".gif", ".webp")
|
|
|
|
|
|
class ManifestTests(unittest.TestCase):
|
|
def test_shipped_skins(self):
|
|
manifests = list(SKINS_DIR.glob("*/skin.toml"))
|
|
self.assertTrue(manifests, "No skin packages found")
|
|
for path in manifests:
|
|
with self.subTest(skin=path.parent.name):
|
|
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
self.assertIn(data["schema_version"], (1, 2))
|
|
self.assertEqual(data["id"], path.parent.name)
|
|
self.assertRegex(data["id"], r"^[a-z0-9._-]{1,64}$")
|
|
# Mirrors the Server catalog: name, version and base are required, author is not.
|
|
for key in ("name", "version", "base"):
|
|
self.assertTrue(data[key].strip(), key)
|
|
self.assertIn(data["base"], BUILTIN_SKINS + SHARED_BASES + (CANVAS_BASE,))
|
|
# Mirrors the Server catalog: canvas needs schema 2, and schema 2 needs canvas (otherwise the
|
|
# catalog loads the skin as schema 1 and ignores every v2 table).
|
|
self.assertEqual(data["schema_version"] == 2, data["base"] == CANVAS_BASE, "schema 2 <=> canvas")
|
|
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"]
|
|
v2_images = []
|
|
if "border_image" in window:
|
|
assets["candidate_window.border_image.source"] = window["border_image"]["source"]
|
|
v2_images.append(window["border_image"]["source"])
|
|
if "light_source" in window["border_image"]:
|
|
light_source = window["border_image"]["light_source"]
|
|
assets["candidate_window.border_image.light_source"] = light_source
|
|
v2_images.append(light_source)
|
|
for key in ("slice_dip", "stretch_dip", "width_dip"):
|
|
if key in window["border_image"]:
|
|
values = window["border_image"][key]
|
|
self.assertIn(len(values), (1, 2, 4), key)
|
|
self.assertTrue(all(0 <= v <= 500 for v in values), key)
|
|
self.assertIsInstance(window["border_image"].get("fill", True), bool, "fill")
|
|
decorations = window.get("decorations", [])
|
|
self.assertLessEqual(len(decorations), 32, "decorations")
|
|
for i, dec in enumerate(decorations):
|
|
if "image" in dec:
|
|
assets[f"candidate_window.decorations[{i}].image"] = dec["image"]
|
|
v2_images.append(dec["image"])
|
|
self.assertIn(dec.get("anchor", "right"), ("top-left", "top-right", "bottom-left", "bottom-right",
|
|
"left", "right", "top", "bottom", "center"))
|
|
self.assertIsInstance(dec.get("z_index", 2), int, "z_index")
|
|
self.assertTrue(-1000 <= dec.get("z_index", 2) <= 1000, "z_index")
|
|
for key in ("offset_x_dip", "offset_y_dip"):
|
|
self.assertTrue(-2000 <= dec.get(key, 0) <= 2000, key)
|
|
# Mirrors the Server catalog: the toolbar's image mode needs a background image and its size; the
|
|
# images are checked only in that mode, as the catalog ignores them otherwise.
|
|
toolbar = data.get("toolbar", {})
|
|
self.assertIn(toolbar.get("mode", "bar"), ("bar", "image"), "toolbar.mode")
|
|
if toolbar.get("mode") == "image":
|
|
assets["toolbar.image"] = toolbar["image"]
|
|
v2_images.append(toolbar["image"])
|
|
for key in ("image_width_dip", "image_height_dip"):
|
|
self.assertTrue(0 < toolbar[key] <= 2000, f"toolbar.{key}")
|
|
for name, button in toolbar.get("buttons", {}).items():
|
|
for key in ("x_dip", "y_dip"):
|
|
self.assertTrue(0 <= button.get(key, 0) <= 2000, f"toolbar.buttons.{name}.{key}")
|
|
for key in ("width_dip", "height_dip", "icon_size_dip"):
|
|
self.assertTrue(0 < button.get(key, 26) <= 500, f"toolbar.buttons.{name}.{key}")
|
|
if "image" in button:
|
|
assets[f"toolbar.buttons.{name}.image"] = button["image"]
|
|
v2_images.append(button["image"])
|
|
for state, image in button.get("images", {}).items():
|
|
assets[f"toolbar.buttons.{name}.images.{state}"] = image
|
|
v2_images.append(image)
|
|
for image in v2_images:
|
|
self.assertTrue(image.lower().endswith(V2_IMAGE_SUFFIXES), image)
|
|
if "decoration" in window:
|
|
decoration = window["decoration"]
|
|
for key in ("top_inset_dip", "width_dip"):
|
|
self.assertGreater(decoration[key], 0, key)
|
|
if decoration.get("candidate_overlay", False):
|
|
for key in ("candidate_overlay_font_size_dip", "candidate_overlay_min_font_size_dip"):
|
|
self.assertTrue(0 < decoration.get(key, 8) <= 200, key)
|
|
self.assertIn(decoration.get("candidate_overlay_align_h", "center"), ("left", "center", "right"))
|
|
self.assertIn(decoration.get("candidate_overlay_align_v", "center"), ("top", "center", "bottom"))
|
|
self.assertIsInstance(decoration.get("candidate_overlay_index", 1), int)
|
|
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() in V2_IMAGE_SUFFIXES:
|
|
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 key, limit in (("border_width_dip", 4), ("item_corner_radius_dip", 16)):
|
|
if key in window:
|
|
self.assertTrue(0 <= window[key] <= limit, key)
|
|
if "shadow" in window:
|
|
self.assertIn(window["shadow"], ("none", "soft", "strong"))
|
|
if "backdrop" in window:
|
|
self.assertIn(window["backdrop"], ("none", "acrylic", "mica"))
|
|
if "page_arrows" in window:
|
|
self.assertIsInstance(window["page_arrows"], bool, "page_arrows")
|
|
if "fit_content" in window:
|
|
self.assertIsInstance(window["fit_content"], bool, "fit_content")
|
|
if "font_family" in window:
|
|
# Mirrors the Server catalog: the name is pasted into a quoted CSS font-family value.
|
|
self.assertRegex(window["font_family"], r"^[^\x00-\x1f\x7f\"'\\,;{}<>`]+$")
|
|
self.assertLessEqual(len(window["font_family"].encode("utf-8")), 64)
|
|
for theme, colors in data.get("candidate", {}).items():
|
|
if "menu" in colors:
|
|
self.assertIsInstance(colors["menu"], dict, f"candidate.{theme}.menu")
|
|
for key in ("backdrop_opacity", "backdrop_luminosity"):
|
|
if key in colors:
|
|
self.assertTrue(0 <= colors[key] <= 1, f"candidate.{theme}.{key}")
|
|
for theme in data["supports"]["themes"]:
|
|
self.assertIn(theme, data["candidate"])
|
|
if "toolbar" in data:
|
|
self.assertIn(theme, data["toolbar"])
|
|
# The toolbar's drag handle is the theme's accent, like the selection bar and caret; the
|
|
# catalog fills it in from accent when a skin leaves it out.
|
|
accent = data["candidate"][theme].get("accent")
|
|
handle = data.get("toolbar", {}).get(theme, {}).get("handle")
|
|
if accent and handle:
|
|
self.assertEqual(handle.lower(), accent.lower(), f"toolbar.{theme}.handle")
|
|
|
|
def test_default_skin_settings(self):
|
|
# skins/default/<id>/skin.toml is the factory manifest of each built-in skin and of the canvas base: the
|
|
# switches plus the whole look. Only collections that ship it are checked.
|
|
default_dir = SKINS_DIR / "default"
|
|
if not default_dir.is_dir():
|
|
self.skipTest("No default skin settings in this collection")
|
|
manifests = {path.parent.name: path for path in default_dir.glob("*/skin.toml")}
|
|
self.assertEqual(sorted(manifests), sorted(BUILTIN_SKINS + (CANVAS_BASE,)))
|
|
for skin_id, path in manifests.items():
|
|
with self.subTest(skin=skin_id):
|
|
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
self.assertEqual(data["schema_version"], 1)
|
|
self.assertEqual(data["id"], skin_id)
|
|
window = data.get("candidate_window", {})
|
|
for switch in ("page_arrows", "fit_content", "expand_panel"):
|
|
self.assertIsInstance(window.get(switch, True), bool, switch)
|
|
# The renderer has no built-in look left to fall back on, so every factory manifest spells out
|
|
# both themes, the menu and the toolbar.
|
|
for theme in ("dark", "light"):
|
|
colors = data["candidate"][theme]
|
|
for key in ("surface", "border", "text", "number", "accent", "selected", "hover"):
|
|
self.assertIn(key, colors, f"candidate.{theme}.{key}")
|
|
self.assertIn("menu", colors, f"candidate.{theme}.menu")
|
|
self.assertIn(theme, data["toolbar"], f"toolbar.{theme}")
|
|
accent = colors["accent"]
|
|
self.assertEqual(data["toolbar"][theme]["handle"].lower(), accent.lower(), f"toolbar.{theme}.handle")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|