diff --git a/.gitattributes b/.gitattributes index 2cbddfac..e761ccff 100644 --- a/.gitattributes +++ b/.gitattributes @@ -13,6 +13,10 @@ *.mp3 binary *.onnx binary *.png binary +# Skin images, animated ones included (schema 2 skins accept APNG, GIF and WebP). +*.apng binary +*.gif binary +*.webp binary # The vendored Google Pinyin dictionary sources are UTF-16 and carry a .txt suffix. The heuristic # does classify them today, on the NUL bytes UTF-16 puts in every ASCII character, but they are diff --git a/examples/skin-examples/README.md b/examples/skin-examples/README.md index 91a01d63..5ad2b959 100644 --- a/examples/skin-examples/README.md +++ b/examples/skin-examples/README.md @@ -33,11 +33,17 @@ ```text examples/skin-examples/ ├─ skins/ -│ └─ niya-demo/ # 一个皮肤一个文件夹,文件夹名 = id -│ ├─ skin.toml # 元信息、候选框、工具栏、授权 +│ ├─ niya-demo/ # 一个皮肤一个文件夹,文件夹名 = id +│ │ ├─ skin.toml # 元信息、候选框、工具栏、授权 +│ │ └─ assets/ +│ │ ├─ character.png # 卡片上方的装饰图 +│ │ └─ background.png # 卡片背景图 +│ └─ canvas-demo/ # schema_version 2 + canvas 基底的全贴图样例 +│ ├─ skin.toml │ └─ assets/ -│ ├─ character.png # 卡片上方的装饰图 -│ └─ background.png # 卡片背景图 +│ ├─ panel.png # 九宫格面板(2 倍图) +│ ├─ leaf.png # 面板下层挂件 +│ └─ sparkle.apng # 面板上层的循环 APNG 挂件 ├─ schema/ # manifest 字段说明 ├─ tests/ # CI 用的 manifest 与安装脚本测试 ├─ preview.html # 本地预览 @@ -103,12 +109,41 @@ icon = "#f6e4eb" 完整字段见 [schema/README.md](schema/README.md)。 +## 全贴图皮肤(schema_version 2) + +`canvas-demo` 演示 v2 皮肤:`schema_version = 2`、`base = "canvas"`,候选框的外观全部来自贴图。 + +```toml +schema_version = 2 +base = "canvas" # v2 必须以 canvas 为 base + +[candidate_window.border_image] +source = "assets/panel.png" # 64×64 的 2 倍图 +slice_dip = [20] # 切片线,按源图像素 +width_dip = [10] # 边框绘制宽度,按 DIP:2 倍图写切片的一半,200% 缩放下逐像素清晰 + +[[candidate_window.decorations]] +image = "assets/sparkle.apng" # APNG / GIF 动图会播放 +anchor = "top-right" +offset_x_dip = 10 +offset_y_dip = -12 +width_dip = 24 +height_dip = 24 +z_index = 2 # z_index < 1 画在面板之下,其余画在面板之上 +``` + +- `canvas` 本身透明、无描边、无圆角、无阴影,不能单独选用;面板图、挂件和配色都由皮肤给出。 +- 面板四角画死的花纹要落在切片之内,否则会被拉伸。中心块可以用 `fill = false` 留空。 +- 文字离面板外缘的距离用 `content_insets_dip`,挂件伸出卡片时用 `outer_insets_dip` 给窗口留出空白。 +- 动图在系统关闭「动画效果」时只显示第一帧。动图解码后合计不超过 64 MB、最多 1024 帧,超出时只显示第一帧。 +- 样例里的三张图都是程序生成的几何图形,随本目录以 MIT 授权,可以直接改用。 + ## 制作约束 - 只用 `skin.toml` 和本地图片,不支持自定义 JavaScript,也不再读取 `cand.css` 或工具栏 CSS。 - 图片路径相对于皮肤目录,不能指向目录之外。 - `id` 只能包含小写字母、数字、点、下划线和连字符,最长 64 个字符。 -- 单张图片建议不超过 500 KB(CI 会检查)。 +- 单张图片建议不超过 500 KB(CI 会检查 PNG、APNG、GIF 和 WebP)。 ## 测试 diff --git a/examples/skin-examples/schema/README.md b/examples/skin-examples/schema/README.md index bfea52cb..f14c2bfd 100644 --- a/examples/skin-examples/schema/README.md +++ b/examples/skin-examples/schema/README.md @@ -1,12 +1,15 @@ # 外部皮肤 manifest(TOML) -每个皮肤目录需要 `skin.toml`,`id` 必须与文件夹名一致。`schema_version` 支持 `1` 与 `2`(v2 为面向全贴图与复杂立绘皮肤的高级规范)。候选框由 Direct2D 原生渲染器绘制;v2 的部分字段在原生端仍在补全,下文标「v2」的字段以实际效果为准。 +每个皮肤目录需要 `skin.toml`,`id` 必须与文件夹名一致。`schema_version` 支持 `1` 与 `2`:v2 是面向全贴图与复杂立绘皮肤的规范,必须以 [`canvas`](#canvas-基底v2) 为 `base`,由 Direct2D 候选窗完整绘制(九宫格面板、上下两层挂件、动图)。 候选框和悬浮工具栏都由 `skin.toml` 声明,不要再提供 `cand.css` 或工具栏 CSS。所有图片路径都相对于皮肤目录,且不能指向目录之外。 ## 顶层字段 -- 必填:`schema_version`(`1` 或 `2`)、`id`、`name`、`version`、`base`(继承的内置皮肤:`fluent`、`wechat`、`graphite`、`willow_green`、`autumn_osmanthus`、`microsoft`;或与 msime 共享的基底:`system`、`shuishan`、`light`、`paper`、`night`、`ink`,按 `fluent` 的结构绘制并补上基底配色,固定明暗的基底只在对应的 `themes` 下生效) +- 必填:`schema_version`(`1` 或 `2`)、`id`、`name`、`version`、`base`(继承的内置皮肤:`fluent`、`wechat`、`graphite`、`willow_green`、`autumn_osmanthus`、`microsoft`;或与 msime 共享的基底:`system`、`shuishan`、`light`、`paper`、`night`、`ink`,按 `fluent` 的结构绘制并补上基底配色,固定明暗的基底只在对应的 `themes` 下生效;或全贴图基底 `canvas`,只用于 v2) +- `schema_version` 与 `base` 的搭配: + - `schema_version = 2` 必须写 `base = "canvas"`。写了 2 而 `base` 是别的基底时,皮肤仍可使用,但按 `schema_version = 1` 加载,下面标「v2」的表和键全部忽略(连同其中的错误),设置页的诊断信息里会多一条说明。 + - `schema_version = 1` 不能用 `canvas`:没有面板贴图它只是一张透明卡片,这种皮肤不会出现在可选列表里。 - 可选:`author`、`description` - `[supports]`:`layouts`(`horizontal` / `vertical`)、`themes`(`dark` / `light`) - `[license]`:`code`、`assets` @@ -38,25 +41,35 @@ - `fit`:`cover`(铺满裁切)| `contain`(完整显示)| `stretch`(拉伸) - `opacity`:`0`–`1` +### `canvas` 基底(v2) + +全贴图皮肤的地基:面板透明、没有描边、直角、默认没有阴影,候选框长什么样完全由下面的九宫格面板图和挂件决定。它只能作为 `base`,不能在设置页单独选用。 + +- 文字与高亮用不依赖底色的中性色:深色主题近白文字(`#f2f2f2`)、白色半透明的选中 / 悬停底,浅色主题近黑文字(`#1a1a1a`)、黑色半透明的选中 / 悬停底;高亮圆角 4,可用 `item_corner_radius_dip` 改。贴图偏深或偏浅时用 `[candidate.dark]` / `[candidate.light]` 改配色。 +- v1 的键照样可用:配色、`font_family`、`page_arrows`、`fit_content`、`min_width_dip`、`corner_radius_dip`、`border_width_dip`、`shadow`(写 `soft` / `strong` 才有阴影)、`[candidate_window.decoration]`、`[candidate_window.background]`、`[toolbar]`。写了 `border_image` 时,卡片的底色、描边、圆角、阴影和 `background` 背景图都让给面板图。 +- 右键菜单与悬浮工具栏没有贴图,沿用 `fluent` 的样式,可用 `[candidate.*.menu]` 与 `[toolbar]` 改。翻页箭头、`fit_content` 的出厂值也取 `fluent` 的默认皮肤设置。 + ### `[candidate_window.border_image]`(v2 可选) -九宫格背景拉伸,面板整体贴图,用于全贴图与复杂纹理面板。 +九宫格面板图,语义同 CSS `border-image`(`border-image-repeat: stretch`):四角按边宽缩放,四边沿长度方向拉伸,中心双向拉伸。 -- `source`:图片路径(如 `assets/skin1.png`,支持 APNG/WebP/PNG) -- `slice_dip`:`[top, right, bottom, left]` 切片线位置,正数数组,支持 1~4 个值 -- `stretch_dip`:可选,可拉伸区,语义同 `slice_dip` 但更直白,给出时覆盖它。**面板图里画死的装饰必须落在可拉伸区之外**,否则会被横向拉长;例如帆船画在右侧,就把右侧切片放到船身左侧 -- `width_dip`:可选,边框绘制宽度,支持 1~4 个值 -- `fill`:可选,布尔值,是否保留九宫格中心内容区域,默认为 `true` +- `source`:图片路径,`.png`、`.apng`、`.gif` 或 `.webp` +- `slice_dip`:切片线位置,**按源图像素计**(同 CSS `border-image-slice` 的数值,键名里的 `_dip` 是历史写法),1、2 或 4 个 `0`–`500` 的数,按 CSS 简写展开成上右下左(1 个值四边相同,2 个值是「上下 左右」) +- `stretch_dip`:可选,同为源图像素,给出时代替 `slice_dip`。**面板图里画死的装饰必须落在切片之内(可拉伸区之外)**,否则会被拉长;例如帆船画在右侧,就把右侧切片放到船身左侧 +- `width_dip`:可选,四边在屏幕上画多宽,**按 DIP 计**(同 CSS `border-image-width`),写法同上。不写时取切片的数值当 DIP。给 2 倍图时写成切片的一半,200% 缩放下逐像素清晰、100% 下是缩小一半的同一张图。两条对边加起来比面板还宽时四边按同一比例缩小 +- `fill`:可选,布尔值,是否画中心块,默认 `true`;`false` 时中心留空、透出桌面 + +面板图画在卡片的底色之上、文字之下,不按圆角裁剪(同 CSS)。动图面板也会播放,但面板一般建议用静态图,把动画留给挂件。 ### `[candidate_window.content_insets_dip]`(v2 可选) -候选框内容与文字的安全内边距,**从面板外缘量起**,用来避开贴图四周自带的花纹或立绘。渲染时会把该方向的边框宽度扣掉,落到内容盒上。 +候选框内容与文字的安全内边距,**从面板外缘量起**,用来避开贴图四周自带的花纹或立绘。某一边小于九宫格该边的边宽(`width_dip`)时按边宽算,文字不会压在边框上(同 CSS 的 `border + padding`)。不写时是边宽再加 5 DIP。 -- `top` / `right` / `bottom` / `left`:四边内边距浮点数 +- `top` / `right` / `bottom` / `left`:四边内边距,`top` / `bottom` `0`–`200`,`left` / `right` `0`–`500` ### `[candidate_window]` 的 `line_gap_dip`(v2 可选) -预编辑行与候选行之间的行距,`0`–`100`。不写则沿用 `base` 的行距。 +预编辑行与候选之间的距离,`0`–`100`。只影响这一处,竖排时候选与翻页箭头之间的间距不变。不写则沿用 `base` 的行距。 ### `[candidate_window.outer_insets_dip]`(v2 可选) @@ -68,13 +81,25 @@ ### `[[candidate_window.decorations]]`(v2 可选,数组表) -自由锚定立绘挂件层,支持叠加角色立绘与 APNG 动图,层数不限。 +自由锚定的立绘挂件,可以叠加角色立绘与动图,最多 32 层。 -- `image`:图片路径 -- `anchor`:`top-left` | `top-right` | `bottom-left` | `bottom-right` | `left` | `right` | `top` | `bottom` | `center` -- `width_dip` / `height_dip`:挂件的尺寸 -- `offset_x_dip` / `offset_y_dip`:相对锚点的平移量,正数向右、向下,两个后端同一套语义 -- `z_index`:层级,默认为 `2` +- `image`:图片路径,`.png`、`.apng`、`.gif` 或 `.webp` +- `anchor`:挂件框贴着面板的哪个位置:`top-left` | `top-right` | `bottom-left` | `bottom-right` | `left` | `right` | `top` | `bottom` | `center`,默认 `right` +- `width_dip` / `height_dip`:挂件框的尺寸,`0`–`2000`(不含 0);图片在框里等比缩放、居中(同 CSS `background-size: contain`) +- `offset_x_dip` / `offset_y_dip`:相对锚点的平移量,`-2000`–`2000`,正数向右、向下 +- `z_index`:层级,`-1000`–`1000` 的整数,默认 `2`。面板自身相当于 `1`:**`z_index < 1` 的挂件画在面板之下**(只有伸出面板的部分看得见),其余画在面板与文字之上。同层按 `z_index` 从小到大、相同时按书写顺序叠放 + +挂件不参与布局,也不接收点击;伸到面板外面时要用 `outer_insets_dip` 给窗口留出空白。 + +### 动图(v2) + +v2 皮肤里的图片可以是动图:APNG(按 [APNG 规范](https://wiki.mozilla.org/APNG_Specification) 解码)、GIF,以及系统 WebP 解码器能逐帧读出的动画 WebP(每帧 100 ms)。 + +- 帧时长与浏览器一致:`delay_den` 为 0 按 100 算,10 ms 及以下按 100 ms;APNG 的 `num_plays`、GIF 的 NETSCAPE 循环次数生效,播完停在最后一帧。 +- 动画从候选窗出现时开始,同一次显示里打字不会让它重头开始;候选窗隐藏或换皮肤后,下一次显示从第一帧开始。 +- 系统关闭了动画效果(设置 → 辅助功能 → 视觉效果 → 动画效果)时显示静态图:APNG 显示它的默认图(通常就是第一帧),GIF 显示第一帧。 +- 一张动图超过 1024 帧或解码后超过 64 MB、文件损坏或不合规范时只显示第一帧(静态图)。所有动图的帧合计受 64 MB 显存预算约束。 +- v1 皮肤里的图片始终是静态首帧,与以往一致。 ## `[candidate.dark]` / `[candidate.light]` diff --git a/examples/skin-examples/skins/canvas-demo/assets/leaf.png b/examples/skin-examples/skins/canvas-demo/assets/leaf.png new file mode 100644 index 00000000..490a29ef Binary files /dev/null and b/examples/skin-examples/skins/canvas-demo/assets/leaf.png differ diff --git a/examples/skin-examples/skins/canvas-demo/assets/panel.png b/examples/skin-examples/skins/canvas-demo/assets/panel.png new file mode 100644 index 00000000..2baa7bbb Binary files /dev/null and b/examples/skin-examples/skins/canvas-demo/assets/panel.png differ diff --git a/examples/skin-examples/skins/canvas-demo/assets/sparkle.apng b/examples/skin-examples/skins/canvas-demo/assets/sparkle.apng new file mode 100644 index 00000000..16270818 Binary files /dev/null and b/examples/skin-examples/skins/canvas-demo/assets/sparkle.apng differ diff --git a/examples/skin-examples/skins/canvas-demo/skin.toml b/examples/skin-examples/skins/canvas-demo/skin.toml new file mode 100644 index 00000000..26fe8fc1 --- /dev/null +++ b/examples/skin-examples/skins/canvas-demo/skin.toml @@ -0,0 +1,92 @@ +# 水杉输入法 schema_version 2 皮肤样例:在 canvas 全贴图基底上,用九宫格面板图、上下两层挂件和一个循环播放的 +# APNG 小动画拼出整个候选框。v1 的键(配色、字体、翻页箭头、工具栏……)在这里照样可用,写法见 niya-demo。 +# 所有图片都是程序生成的几何图形,随本目录以 MIT 授权。 + +schema_version = 2 # v2 必须以 canvas 为 base;写了 2 而 base 不是 canvas 的皮肤按 1 处理 +id = "canvas-demo" +name = "Canvas Demo" +version = "1.0.0" +author = "Metasequoia IME contributors" +description = "canvas 全贴图基底样例:夜蓝玻璃九宫格面板、面板后探出的叶子、右上角循环闪烁的 APNG 星光。" +# canvas:透明面板、无描边、无圆角、无阴影,外观全部来自下面的贴图。它只能当 base,不能单独选用。 +base = "canvas" + +[supports] +layouts = ["horizontal", "vertical"] +themes = ["dark", "light"] + +[candidate_window] +min_width_dip = 168 +item_corner_radius_dip = 5 # 选中 / 悬停高亮的圆角 +# 预编辑行与候选之间的距离,0–100 DIP;只影响这一处,不影响候选与翻页箭头之间。 +line_gap_dip = 4 + +# 九宫格面板图,语义同 CSS border-image(stretch)。 +# panel.png 是 64×64 的 2 倍图:切片按源图像素写 20,边框按屏幕 DIP 画 10 宽,所以 200% 缩放下逐像素清晰, +# 100% 下是缩小一半的同一张图。四角的小菱形落在切片之内,不会被拉伸。 +[candidate_window.border_image] +source = "assets/panel.png" +slice_dip = [20] # 切片线,源图像素,1/2/4 个值(上右下左,同 CSS) +width_dip = [10] # 边框绘制宽度,DIP;不写时取切片的数值 +fill = true # 画九宫格中心块;false 时中心留空 + +# 内容(预编辑与候选)离面板外缘的距离,DIP;某一边小于边框宽度时按边框宽度算。 +[candidate_window.content_insets_dip] +top = 10 +right = 12 +bottom = 10 +left = 12 + +# 挂件伸到卡片外面时窗口要多留的透明空白,DIP。 +[candidate_window.outer_insets_dip] +top = 12 +right = 10 +bottom = 12 +left = 14 + +# 挂件:z_index < 1 的画在面板之下,其余画在面板与文字之上;同层按 z_index、再按书写顺序叠放。 +# 图片在 width_dip × height_dip 的框里等比缩放、居中;anchor 是框贴着面板的哪个位置,offset 正数向右、向下。 +[[candidate_window.decorations]] +image = "assets/leaf.png" # 64×64 的 2 倍图,画成 32×32 DIP +anchor = "bottom-left" +offset_x_dip = -14 +offset_y_dip = 12 +width_dip = 32 +height_dip = 32 +z_index = 0 # 在面板之下:只有伸出面板的那一截看得见 + +[[candidate_window.decorations]] +image = "assets/sparkle.apng" # 8 帧、每帧 100 ms、无限循环;系统关闭动画效果时只显示第一帧 +anchor = "top-right" +offset_x_dip = 10 +offset_y_dip = -12 +width_dip = 24 +height_dip = 24 +z_index = 2 + +# 面板是深色玻璃,深浅两套主题都用浅色文字。 +[candidate.dark] +accent = "#5eead4" +selected = "rgba(94, 234, 212, 0.20)" +hover = "rgba(94, 234, 212, 0.10)" +text = "#e8f1ff" +number = "rgba(232, 241, 255, 0.60)" +preedit_text = "#99f6e4" +selected_text = "#ffffff" +selected_bar = "#fde68a" + +[candidate.light] +accent = "#5eead4" +selected = "rgba(94, 234, 212, 0.22)" +hover = "rgba(94, 234, 212, 0.12)" +text = "#f1f5ff" +number = "rgba(241, 245, 255, 0.62)" +translation = "#c4b5fd" # 浅色主题的默认翻译色偏深,压在深色面板上看不清 +preedit_text = "#99f6e4" +selected_text = "#ffffff" +selected_bar = "#fde68a" + +[license] +code = "MIT" +assets = "MIT" +source = "Generated geometry by the Metasequoia IME contributors." diff --git a/examples/skin-examples/skins/niya-demo/skin.toml b/examples/skin-examples/skins/niya-demo/skin.toml index 580054cb..faea534f 100644 --- a/examples/skin-examples/skins/niya-demo/skin.toml +++ b/examples/skin-examples/skins/niya-demo/skin.toml @@ -22,6 +22,7 @@ author = "Metasequoia IME contributors" # 可选,作者,最长 120 字节 description = "天元突破·妮娅:卡片上方右对齐的人物装饰、带星点的背景图、12dip 圆角,以及配套的悬浮工具栏配色。" # 可选,最长 500 字节 # 必填,继承的内置皮肤:fluent | wechat | graphite | willow_green | autumn_osmanthus | microsoft, # 或与 msime 共享的基底:system | shuishan | light | paper | night | ink(按 fluent 结构绘制)。 +# 全贴图基底 canvas 只用于 schema_version 2 的皮肤,见 canvas-demo。 # 下面没写的键、以及皮肤无法描述的排版(字号、间距、内边距)都取自 base。 base = "fluent" diff --git a/examples/skin-examples/tests/test_manifests.py b/examples/skin-examples/tests/test_manifests.py index d413bdf0..f10dc365 100644 --- a/examples/skin-examples/tests/test_manifests.py +++ b/examples/skin-examples/tests/test_manifests.py @@ -11,6 +11,10 @@ SKINS_DIR = Path(os.environ.get("MSIME_SKINS_DIR", ROOT / "skins")).resolve() BUILTIN_SKINS = ("fluent", "wechat", "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 may reference (border_image.source, decorations.image). +V2_IMAGE_SUFFIXES = (".png", ".apng", ".gif", ".webp") class ManifestTests(unittest.TestCase): @@ -26,17 +30,39 @@ class ManifestTests(unittest.TestCase): # 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) + 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"] - for i, dec in enumerate(window.get("decorations", [])): + v2_images.append(window["border_image"]["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) + for image in v2_images: + self.assertTrue(image.lower().endswith(V2_IMAGE_SUFFIXES), image) if "decoration" in window: for key in ("top_inset_dip", "width_dip"): self.assertGreater(window["decoration"][key], 0, key) @@ -46,7 +72,7 @@ class ManifestTests(unittest.TestCase): 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": + 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, {}): diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 866fd22c..aa87bad6 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -268,6 +268,8 @@ set(SRC_FILES "./src/utils/window_utils.h" "./src/window/candidate_presenter.cpp" "./src/window/candidate_presenter.h" + "./src/window/candidate_skin_layers.cpp" + "./src/window/candidate_skin_layers.h" "./src/window/candidate_skin_overlay.cpp" "./src/window/candidate_skin_overlay.h" "./src/window/candidate_skin_palette.cpp" @@ -494,6 +496,8 @@ add_executable(${SETTINGS_EXECUTABLE_NAME} WIN32 "./src/settings/native/native_page_feedback.cpp" "./src/skin/candidate_skin_catalog.cpp" "./src/skin/candidate_skin_catalog.h" + "./src/window/candidate_skin_layers.cpp" + "./src/window/candidate_skin_layers.h" "./src/settings/dictionary_manager.cpp" "./src/settings/dictionary_manager.h" "./src/settings/dictionary_validation.cpp" diff --git a/server/docs/ui-backends.md b/server/docs/ui-backends.md index 9751cbd6..52d62f9a 100644 --- a/server/docs/ui-backends.md +++ b/server/docs/ui-backends.md @@ -38,13 +38,26 @@ input state. ## v2 skins -`schema_version = 2` skins (nine-slice panel images, freely anchored decorations, space -outside the panel) are rendered by `CandidatePresenter`'s native scene: the panel is drawn -by `msimeui::Card::SetBackgroundImageNineSlice`, decorations by the `CandidateSkinOverlay` -layer above the card, and `outer_insets_dip` becomes extra window space around the card. -Parts of v2 are still being completed on the native side; see the `TODO(v2 皮肤)` notes in -`candidate_presenter.cpp` and `candidate_skin_overlay.cpp`. +`schema_version = 2` skins must use `base = "canvas"` (see `skin/candidate_skin_catalog.h`); +the fields are documented in `examples/skin-examples/schema/README.md`. `canvas` is the +full-texture base: transparent panel, no stroke, square corners, no shadow by default. It is +only usable as a base and is not part of `BuiltInIds()`. -`anchor` and `offset_*_dip` follow one convention: positive values move right and down. -`content_insets_dip` is measured from the outer edge of the panel and is carried by the -card padding. +- Geometry lives in `window/candidate_skin_layers.*` (pure arithmetic, no rendering) and is + shared by the candidate window and the settings preview: `ResolveNineSlice` follows CSS + `border-image` (slices in source-image pixels, widths in DIP); `ResolveContentPadding` + measures `content_insets_dip` from the panel's outer edge and never below the border width; + `SplitDecorations` stably sorts decorations and splits them at `kDecorationFrontZIndex` (1) + into a layer below and a layer above the card. +- In `CandidatePresenter`'s scene the card and two `CandidateSkinOverlay` layers share one + cell: back decorations, card, front decorations. Overlay layers measure 0 and do not hit + test. The panel is drawn by `msimeui::Card::SetBackgroundNineSlice`; with a panel image the + card's fill, stroke, radius and shadow are cleared. `line_gap_dip` becomes the preedit row's + bottom margin, so list/pager spacing is unaffected. `outer_insets_dip` becomes extra window + space around the card. `anchor` and `offset_*_dip`: positive values move right and down. +- Animation: images are fetched through `DeviceResources::GetImageFrame`. `Present` samples + the animation clock with `BeginFrame`, renders, then reads `Scene::GetNextFrameDeadline()` + and, only while the candidate window is shown, arms one one-shot coalescable timer + (`kCandidateAnimationTimerId`) that presents again. Hiding, a deferred show and a skin change + stop it; the clock restarts when the window goes from hidden to shown. With + `SPI_GETCLIENTAREAANIMATION` off, images are drawn still (first frame), as for v1 skins. diff --git a/server/src/settings/native/native_page_skin_common.cpp b/server/src/settings/native/native_page_skin_common.cpp index f6dea51f..1cee63b6 100644 --- a/server/src/settings/native/native_page_skin_common.cpp +++ b/server/src/settings/native/native_page_skin_common.cpp @@ -371,7 +371,7 @@ std::string SkinBase(const SettingsModel &model, const std::string &id) if (const Json *external = FindExternalSkin(model, id)) { const std::string base = external->value("base", std::string("fluent")); - return IsBuiltinSkin(base) ? base : "fluent"; + return IsBuiltinSkin(base) || base == "canvas" ? base : "fluent"; } return "fluent"; } @@ -611,6 +611,21 @@ CandidateLook ResolveCandidateLook(const SettingsModel &model, const std::string k.wrapperGap = 2.0f; } } + else if (base == "canvas") + { + // The full-texture base of the candidate window (CandidatePresenter): no surface, frame, + // radius or shadow; neutral text and highlights that read on most textures. + k.bg = None(); + k.border = None(); + k.borderWidth = 0.0f; + k.radius = 0.0f; + k.edgeRadius = 0.0f; + k.text = light ? Hex(0x1A1A1A) : Hex(0xF2F2F2); + k.num = light ? Rgba(26, 26, 26, 0.6f) : Hex(0xF2F2F2, 0.62f); + k.hover = light ? Rgba(0, 0, 0, 0.06f) : Rgba(255, 255, 255, 0.09f); + k.selected = light ? Rgba(0, 0, 0, 0.10f) : Rgba(255, 255, 255, 0.16f); + k.shadowAlpha1 = k.shadowAlpha2 = 0.0f; + } k.bar = k.accent; // The generated CSS of an external skin (skin.ts candidatePreviewCss). @@ -700,6 +715,38 @@ CandidateLook ResolveCandidateLook(const SettingsModel &model, const std::string k.decorationWidth = NumberOr(external, "decorationWidthDip", 0.0f, 4096.0f); k.decorationAlign = StringOr(external, "decorationAlign"); } + // canvas skins: content padding from the panel's outer edge, and the nine-sliced panel that + // replaces the frame's fill, stroke, radius and shadow. + const Json *canvas = external->contains("canvas") ? &(*external)["canvas"] : nullptr; + const auto edges = [](const Json *object, const char *key) { + Thickness result{}; + if (object && object->is_object() && object->contains(key) && (*object)[key].is_array() && + (*object)[key].size() == 4) + { + const Json &v = (*object)[key]; + const auto at = [&v](size_t i) { return v[i].is_number() ? v[i].get() : 0.0f; }; + result = {at(3), at(0), at(1), at(2)}; // JSON is top, right, bottom, left + } + return result; + }; + if (canvas && canvas->is_object()) + { + k.containerPad = edges(canvas, "padding"); + const Json *panel = canvas->contains("panel") ? &(*canvas)["panel"] : nullptr; + const std::string image = panel && panel->is_object() ? panel->value("image", std::string()) : ""; + if (!image.empty()) + { + k.panelImage = SkinResourcePath(model, skinId, image); + k.panelSlice = edges(panel, "slice"); + k.panelWidth = edges(panel, "width"); + k.panelFill = panel->value("fill", true); + k.bg = None(); + k.border = None(); + k.borderWidth = 0.0f; + k.radius = 0.0f; + k.shadowAlpha1 = k.shadowAlpha2 = 0.0f; + } + } } return k; } @@ -1219,7 +1266,12 @@ void CandidatePreview::Render(msimeui::DeviceResources &dr) m.shadows[1].Render(target, box, k.radius, &second, 1); } FillRounded(dr, box, k.radius, k.bg); - if (!k.backgroundImage.empty()) + if (!k.panelImage.empty()) + { + if (ID2D1Bitmap *bitmap = CachedBitmap(dr, k.panelImage, nullptr)) + msimeui::DrawNineSlice(target, bitmap, box, msimeui::NineSlice{k.panelSlice, k.panelWidth, k.panelFill}); + } + else if (!k.backgroundImage.empty()) { D2D1_SIZE_F size{}; if (ID2D1Bitmap *bitmap = CachedBitmap(dr, k.backgroundImage, &size)) diff --git a/server/src/settings/native/native_page_skin_common.h b/server/src/settings/native/native_page_skin_common.h index 16dccd86..537847e7 100644 --- a/server/src/settings/native/native_page_skin_common.h +++ b/server/src/settings/native/native_page_skin_common.h @@ -31,7 +31,8 @@ bool IsBuiltinSkin(const std::string &id); // External skin entry of appearance.external_candidate_skins, or null. const Json *FindExternalSkin(const SettingsModel &model, const std::string &id); -// Base of a skin id: the id itself for a built-in skin, the manifest's base otherwise. +// Base of a skin id: the id itself for a built-in skin, the manifest's base otherwise (a built-in +// skin or canvas; shared bases arrive already mapped to fluent). std::string SkinBase(const SettingsModel &model, const std::string &id); // skins// under appearance.candidate_skin_directory. std::wstring SkinResourcePath(const SettingsModel &model, const std::string &id, const std::string &relative); @@ -93,6 +94,11 @@ struct CandidateLook std::wstring decorationImage; std::string decorationAlign; float decorationTop = 0.0f, decorationWidth = 0.0f; + // Panel image of a canvas (schema 2) skin, nine-sliced over the frame like CSS border-image: + // slices in image pixels, widths in DIPs. Empty when the skin has none. + std::wstring panelImage; + Thickness panelSlice = {}, panelWidth = {}; + bool panelFill = true; }; CandidateLook ResolveCandidateLook(const SettingsModel &model, const std::string &skinId, bool light, bool horizontal, diff --git a/server/src/settings/settings_app.cpp b/server/src/settings/settings_app.cpp index b6cf8956..df38b9f0 100644 --- a/server/src/settings/settings_app.cpp +++ b/server/src/settings/settings_app.cpp @@ -22,6 +22,7 @@ #include #include #include "skin/candidate_skin_catalog.h" +#include "window/candidate_skin_layers.h" #include "utils/common_utils.h" #include "utils/single_instance.h" #include "voice-input/voice_providers.h" @@ -184,6 +185,28 @@ nlohmann::json CandidateColorsToJson(const CandidateSkinCatalog::CandidateColors return json; } +// canvas(v2)皮肤给设置页预览的面板几何,与候选窗同一套换算;四边按「上右下左」。不是 v2 时为 null。 +nlohmann::json CanvasSkinPreviewJson(const CandidateSkinCatalog::Package &skin) +{ + if (skin.schemaVersion < 2) + return nullptr; + const auto edges = [](const CandidateSkinLayers::Edges &e) { + return nlohmann::json::array({e.top, e.right, e.bottom, e.left}); + }; + // 5 DIP 是候选窗 canvas 基底自己的内边距(CandidatePresenter 的 containerPad)。 + nlohmann::json json = {{"padding", edges(CandidateSkinLayers::ResolveContentPadding(skin, 5.0))}, + {"panel", nullptr}}; + if (skin.borderImage && !skin.borderImage->source.empty()) + { + const CandidateSkinLayers::NineSlice nine = CandidateSkinLayers::ResolveNineSlice(*skin.borderImage); + json["panel"] = {{"image", skin.borderImage->source}, + {"slice", edges(nine.slice)}, + {"width", edges(nine.width)}, + {"fill", nine.fill}}; + } + return json; +} + nlohmann::json ToolbarColorsToJson(const CandidateSkinCatalog::ToolbarColors &colors) { nlohmann::json json = nlohmann::json::object(); @@ -380,6 +403,8 @@ std::wstring BuildConfigMessage(bool refresh_skin_catalog) skin.toolbarCornerRadiusDip ? nlohmann::json(*skin.toolbarCornerRadiusDip) : nlohmann::json()}, // 已按 base 的默认皮肤清单解析好,页面预览直接用。 {"pageArrows", CandidateSkinCatalog::ResolvePageArrows(skins_root, skin.id, &skin)}, + {"schemaVersion", skin.schemaVersion}, + {"canvas", CanvasSkinPreviewJson(skin)}, {"compatible", CandidateSkinCatalog::Supports(skin, skin_layout, skin_theme)}}); } } diff --git a/server/src/skin/candidate_skin_catalog.cpp b/server/src/skin/candidate_skin_catalog.cpp index a9a96c80..565019c7 100644 --- a/server/src/skin/candidate_skin_catalog.cpp +++ b/server/src/skin/candidate_skin_catalog.cpp @@ -77,7 +77,7 @@ void AdaptSharedBase(Package &package) { return; } - // 所有使用方(含设置预览)仍按本地 CSS / D2D 的 Fluent 结构处理,不能去查不存在的共享 CSS。 + // 所有使用方(含设置预览)仍按 Fluent 的结构处理。 package.base = "fluent"; if (!base->theme) { @@ -235,7 +235,8 @@ bool ReadResource(const toml::table &table, const char *key, std::string &out) return !table.contains(key) || (ReadString(table, key, out, 256, true) && IsSafeRelativeResource(out)); } -// 皮肤颜色会被拼进 WebView2 的 CSS 声明,只放行颜色值会用到的字符,挡住 `;`、`{}` 之类能跳出声明的写法。 +// 颜色按 CSS 颜色写法解析,只放行颜色值会用到的字符,挡住 `;`、`{}` 之类的写法:同一份 skin.toml +// 在各平台通用,这里不能比别处宽松。 bool ReadCssColor(const toml::table &table, const char *key, std::string &out) { return ReadString(table, key, out, 80, false) && std::all_of(out.begin(), out.end(), [](unsigned char ch) { @@ -292,7 +293,7 @@ bool ReadColors(const toml::table *table, CandidateColors &out) return true; } -// 字体族会被拼进 WebView2 的 font-family(加引号)与脚本字符串,挡住引号、反斜杠、分号、花括号、尖括号和控制字符; +// 字体族挡住引号、反斜杠、分号、花括号、尖括号和控制字符,与各平台共用的 skin.toml 规则一致; // 字体名可以是中文,所以非 ASCII 字节一律放行。 bool ReadFontFamily(const toml::table &table, const char *key, std::string &out) { @@ -325,6 +326,60 @@ bool ReadOptionalBounded(const toml::table &table, const char *key, double maxim return true; } +// v2 的四边数值(切片、边宽):1、2 或 4 个 0–500 的数,按 CSS 简写展开。键不存在时什么也不做。 +bool ReadBoxValues(const toml::table &table, const char *key, std::vector &out) +{ + const toml::node *node = table.get(key); + if (!node) + { + return true; + } + const auto *array = node->as_array(); + if (!array || (array->size() != 1 && array->size() != 2 && array->size() != 4)) + { + return false; + } + for (const auto &item : *array) + { + const auto number = item.value(); + if (!number || !std::isfinite(*number) || *number < 0.0 || *number > 500.0) + { + return false; + } + out.push_back(*number); + } + return true; +} + +// 可正可负的可选数值,缺省保持 out 不变。 +bool ReadSignedNumber(const toml::table &table, const char *key, double limit, double &out) +{ + const toml::node *node = table.get(key); + if (!node) + { + return true; + } + double value = 0.0; + if (const auto *floating = node->as_floating_point()) + { + value = floating->get(); + } + else if (const auto *integer = node->as_integer()) + { + value = static_cast(integer->get()); + } + else + { + return false; + } + if (!std::isfinite(value) || std::fabs(value) > limit) + { + return false; + } + out = value; + return true; +} + bool ReadOptionalBool(const toml::table &table, const char *key, std::optional &out) { const toml::node *node = table.get(key); @@ -376,6 +431,24 @@ const std::vector &BuiltInIds() return ids; } +bool IsCanvasBase(const std::string &base) +{ + return base == kCanvasBase; +} + +bool IsAnimatableImagePath(const std::string &path) +{ + const auto dot = path.find_last_of('.'); + if (dot == std::string::npos || path.find('/', dot) != std::string::npos) + { + return false; + } + std::string extension = path.substr(dot); + std::transform(extension.begin(), extension.end(), extension.begin(), + [](unsigned char ch) { return static_cast(std::tolower(ch)); }); + return extension == ".png" || extension == ".apng" || extension == ".gif" || extension == ".webp"; +} + bool IsBuiltIn(const std::string &id) { const auto &ids = BuiltInIds(); @@ -453,11 +526,24 @@ std::optional Load(const std::filesystem::path &skinsRoot, const std::s !ReadString(root, "author", package.author, 120, false) || !ReadString(root, "description", package.description, 500, false) || !ReadString(root, "base", package.base, 32, true) || - (!IsBuiltIn(package.base) && !FindSharedBase(package.base))) + (!IsBuiltIn(package.base) && !FindSharedBase(package.base) && !IsCanvasBase(package.base))) { SetError(error, "manifest 的基本信息无效"); return std::nullopt; } + // canvas 没有自己的面板,只有 v2 的九宫格与挂件能让它可见;v1 皮肤用它只会得到一张透明卡片。 + if (IsCanvasBase(package.base) && schemaVersion < 2) + { + SetError(error, "base = \"canvas\" 只能用于 schema_version = 2 的皮肤"); + return std::nullopt; + } + // v2 字段只在 canvas 上生效:其他 base 的卡片几何与九宫格/挂件互相打架。这种皮肤仍可使用,按 v1 处理。 + if (schemaVersion >= 2 && !IsCanvasBase(package.base)) + { + package.schemaVersion = 1; + package.notices.push_back("schema_version = 2 需要 base = \"canvas\",已按 schema_version = 1 " + "加载,九宫格、挂件等 v2 字段被忽略"); + } const auto *supports = root["supports"].as_table(); if (!supports || !ReadEnumArray(*supports, "layouts", {"horizontal", "vertical"}, package.layouts) || !ReadEnumArray(*supports, "themes", {"dark", "light"}, package.themes)) @@ -563,64 +649,34 @@ std::optional Load(const std::filesystem::path &skinsRoot, const std::s const auto *borderImageTable = borderImageNode->as_table(); BorderImage bi; if (!borderImageTable || !borderImageTable->contains("source") || - !ReadResource(*borderImageTable, "source", bi.source)) + !ReadResource(*borderImageTable, "source", bi.source) || !IsAnimatableImagePath(bi.source)) { - SetError(error, "candidate_window.border_image.source 无效"); + SetError(error, "candidate_window.border_image.source 无效(需为 .png、.apng、.gif 或 .webp)"); return std::nullopt; } - const auto *sliceArr = (*borderImageTable)["slice_dip"].as_array(); - if (!sliceArr || (sliceArr->size() != 1 && sliceArr->size() != 2 && sliceArr->size() != 4)) + if (!borderImageTable->contains("slice_dip") || + !ReadBoxValues(*borderImageTable, "slice_dip", bi.sliceDip)) { - SetError(error, "candidate_window.border_image.slice_dip 必须包含 1、2 或 4 个数值"); + SetError(error, "candidate_window.border_image.slice_dip 必须是 1、2 或 4 个 0–500 的数值"); return std::nullopt; } - for (const auto &val : *sliceArr) + if (!ReadBoxValues(*borderImageTable, "stretch_dip", bi.stretchDip)) { - const auto num = val.value(); - if (!num || *num < 0.0 || *num > 500.0) - { - SetError(error, "candidate_window.border_image.slice_dip 超出范围"); - return std::nullopt; - } - bi.sliceDip.push_back(*num); + SetError(error, "candidate_window.border_image.stretch_dip 必须是 1、2 或 4 个 0–500 的数值"); + return std::nullopt; } - if (const auto *stretchArr = (*borderImageTable)["stretch_dip"].as_array()) + if (!ReadBoxValues(*borderImageTable, "width_dip", bi.widthDip)) { - if (stretchArr->size() != 1 && stretchArr->size() != 2 && stretchArr->size() != 4) - { - SetError(error, "candidate_window.border_image.stretch_dip 必须包含 1、2 或 4 个数值"); - return std::nullopt; - } - for (const auto &val : *stretchArr) - { - const auto num = val.value(); - if (!num || *num < 0.0 || *num > 500.0) - { - SetError(error, "candidate_window.border_image.stretch_dip 超出范围"); - return std::nullopt; - } - bi.stretchDip.push_back(*num); - } + SetError(error, "candidate_window.border_image.width_dip 必须是 1、2 或 4 个 0–500 的数值"); + return std::nullopt; } - if (const auto *widthArr = (*borderImageTable)["width_dip"].as_array()) + std::optional fill; + if (!ReadOptionalBool(*borderImageTable, "fill", fill)) { - if (widthArr->size() != 1 && widthArr->size() != 2 && widthArr->size() != 4) - { - SetError(error, "candidate_window.border_image.width_dip 必须包含 1、2 或 4 个数值"); - return std::nullopt; - } - for (const auto &val : *widthArr) - { - const auto num = val.value(); - if (!num || *num < 0.0 || *num > 500.0) - { - SetError(error, "candidate_window.border_image.width_dip 超出范围"); - return std::nullopt; - } - bi.widthDip.push_back(*num); - } + SetError(error, "candidate_window.border_image.fill 必须是 true 或 false"); + return std::nullopt; } - bi.fill = (*borderImageTable)["fill"].value_or(true); + bi.fill = fill.value_or(true); package.borderImage = bi; } @@ -680,13 +736,19 @@ std::optional Load(const std::filesystem::path &skinsRoot, const std::s SetError(error, "candidate_window.decorations 必须是数组"); return std::nullopt; } + if (decorationsArr->size() > kMaxFreeDecorations) + { + SetError(error, "candidate_window.decorations 最多 32 层"); + return std::nullopt; + } for (const auto &decNode : *decorationsArr) { const auto *decTable = decNode.as_table(); FreeDecoration dec; - if (!decTable || !decTable->contains("image") || !ReadResource(*decTable, "image", dec.image)) + if (!decTable || !decTable->contains("image") || !ReadResource(*decTable, "image", dec.image) || + !IsAnimatableImagePath(dec.image)) { - SetError(error, "candidate_window.decorations.image 无效"); + SetError(error, "candidate_window.decorations.image 无效(需为 .png、.apng、.gif 或 .webp)"); return std::nullopt; } if (decTable->contains("anchor") && @@ -698,12 +760,27 @@ std::optional Load(const std::filesystem::path &skinsRoot, const std::s SetError(error, "candidate_window.decorations.anchor 无效"); return std::nullopt; } - // 这三个键可选,缺省时 table::get 返回空指针,必须经 node_view 取值 - dec.offsetXDip = (*decTable)["offset_x_dip"].value_or(0.0); - dec.offsetYDip = (*decTable)["offset_y_dip"].value_or(0.0); + // offset_x_dip、offset_y_dip、z_index 可选;写了就必须是范围内的数(z_index 为整数)。 + if (!ReadSignedNumber(*decTable, "offset_x_dip", kDecorationOffsetLimitDip, dec.offsetXDip) || + !ReadSignedNumber(*decTable, "offset_y_dip", kDecorationOffsetLimitDip, dec.offsetYDip)) + { + SetError(error, "candidate_window.decorations 的 offset_x_dip / offset_y_dip 必须在 " + "-2000–2000 之间"); + return std::nullopt; + } + if (const toml::node *zNode = decTable->get("z_index")) + { + const auto *zValue = zNode->as_integer(); + if (!zValue || zValue->get() < -kDecorationZIndexLimit || + zValue->get() > kDecorationZIndexLimit) + { + SetError(error, "candidate_window.decorations.z_index 必须是 -1000–1000 的整数"); + return std::nullopt; + } + dec.zIndex = static_cast(zValue->get()); + } dec.widthDip = BoundedNumber(*decTable, "width_dip", 2000.0); dec.heightDip = BoundedNumber(*decTable, "height_dip", 2000.0); - dec.zIndex = static_cast((*decTable)["z_index"].value_or(int64_t{2})); if (dec.widthDip <= 0.0 || dec.heightDip <= 0.0) { SetError(error, "candidate_window.decorations 的 width_dip 与 height_dip 必须大于 0"); @@ -809,6 +886,11 @@ ScanResult Scan(const std::filesystem::path &skinsRoot) auto package = Load(skinsRoot, folder, &error); if (package) { + // 能用但有问题的皮肤照样列出来,问题另记一条,免得作者以为 v2 字段生效了。 + for (const auto ¬ice : package->notices) + { + result.issues.push_back({folder, notice}); + } result.packages.push_back(std::move(*package)); } else diff --git a/server/src/skin/candidate_skin_catalog.h b/server/src/skin/candidate_skin_catalog.h index c3338466..4a5cfe80 100644 --- a/server/src/skin/candidate_skin_catalog.h +++ b/server/src/skin/candidate_skin_catalog.h @@ -1,5 +1,6 @@ #pragma once +#include #include #include #include @@ -46,7 +47,7 @@ struct CandidateColors std::string menuHover; }; -// 悬浮工具栏的配色覆盖,D2D 与 WebView2 两个渲染器都读这一份。留空的项沿用基础皮肤。 +// 悬浮工具栏的配色覆盖。留空的项沿用基础皮肤。 struct ToolbarColors { std::string background; @@ -60,14 +61,16 @@ struct ToolbarColors struct BorderImage { std::string source; - // 四边切片大小(DIP):top, right, bottom, left + // 切片线位置,以源图像素计(同 CSS border-image-slice 的数值,键名沿用 _dip):1/2/4 个值,按 CSS + // 简写展开成上右下左。 std::vector sliceDip; - // 可拉伸区(上右下左,从面板外缘量起):给出时覆盖 sliceDip。 + // 可拉伸区(上右下左,同为源图像素,从图片外缘量起):给出时覆盖 sliceDip。 // 面板图里画死的装饰必须落在可拉伸区之外,否则会被横向拉长。 std::vector stretchDip; - // 四边宽度(DIP):top, right, bottom, left;若为空则沿用 sliceDip + // 四边在屏幕上的绘制宽度(DIP,同 CSS border-image-width);为空时取切片的数值当 DIP。 + // 给 2 倍图时把它写成切片的一半,面板在 200% 缩放下仍按原图像素清晰显示。 std::vector widthDip; - // 是否填充中间内容区域 + // 是否画九宫格中心块(CSS border-image-slice 的 fill) bool fill = true; }; @@ -79,6 +82,14 @@ struct InsetsDip double left = 0.0; }; +// 挂件 z_index 低于它的画在面板(卡片)之下,其余画在面板之上。面板自身相当于 z_index = 1, +// 与样例预览页 .container 的 z-index: 1 一致。 +inline constexpr int kDecorationFrontZIndex = 1; +inline constexpr int kDecorationZIndexLimit = 1000; +inline constexpr double kDecorationOffsetLimitDip = 2000.0; +// 挂件层数上限,挡住把几百张大图塞进一个皮肤的清单。 +inline constexpr size_t kMaxFreeDecorations = 32; + struct FreeDecoration { std::string image; @@ -88,13 +99,17 @@ struct FreeDecoration double offsetYDip = 0.0; double widthDip = 0.0; double heightDip = 0.0; + // -1000–1000;低于 kDecorationFrontZIndex 的在面板之下。同层按声明顺序叠放。 int zIndex = 2; }; struct Package { - // 皮肤规范版本:1 为通用现代卡片皮肤,2 为支持九宫格与自由挂件的高级皮肤。 + // 皮肤规范版本:1 为通用现代卡片皮肤,2 为支持九宫格与自由挂件的高级皮肤。v2 必须以 canvas 为 base, + // 声明 2 而 base 不是 canvas 的按 1 处理(v2 字段全部忽略),并在 notices 里记一条。 int schemaVersion = 1; + // 不妨碍加载的问题;Scan 把它们和加载失败一起列进 issues,设置页的诊断信息会显示。 + std::vector notices; std::string id; std::string name; std::string version; @@ -136,7 +151,7 @@ struct Package ToolbarColors toolbarLight; // v2 专有特性:九宫格贴图边框 std::optional borderImage; - // v2 专有特性:面板内容/文字安全内边距,从面板外缘量起(渲染时扣掉边框宽度)。 + // v2 专有特性:面板内容/文字安全内边距,从面板外缘量起;某一边小于九宫格边框宽度时按边框宽度算。 std::optional contentInsetsDip; // v2 专有特性:预编辑行与候选行之间的行距(DIP,0–100);未设置时沿用 base 的行距。 std::optional lineGapDip; @@ -159,7 +174,7 @@ struct ScanResult std::vector issues; }; -// 内置皮肤的设置清单:/default//skin.toml。外观仍由内置 CSS 与 D2D 配色决定, +// 内置皮肤的设置清单:/default//skin.toml。外观仍由内置的 D2D 配色决定, // 清单只承载皮肤级的开关;外部皮肤没写的开关沿用它 base 的这份清单。 struct DefaultSkin { @@ -176,6 +191,13 @@ inline constexpr bool kDefaultPageArrows = false; // 默认皮肤清单缺失或没写 fit_content 时的取值:卡片保留出厂最小宽度。 inline constexpr bool kDefaultFitContent = false; +// 全贴图基底:透明面板、无描边、无圆角、无阴影,外观完全来自九宫格面板图与挂件,是 v2 与自定义皮肤的地基。 +// 它只能作为外部皮肤的 base,不能单独选用(没有贴图时什么也看不见),也不在 BuiltInIds 里。 +inline constexpr const char *kCanvasBase = "canvas"; +bool IsCanvasBase(const std::string &base); +// v2 图片(border_image.source、decorations.image)允许的扩展名:.png、.apng、.gif、.webp,不分大小写。 +bool IsAnimatableImagePath(const std::string &path); + // 六个内置皮肤的 ID,按设置页的展示顺序。 const std::vector &BuiltInIds(); bool IsBuiltIn(const std::string &id); diff --git a/server/src/window/candidate_presenter.cpp b/server/src/window/candidate_presenter.cpp index 44c06395..2e409f0b 100644 --- a/server/src/window/candidate_presenter.cpp +++ b/server/src/window/candidate_presenter.cpp @@ -8,6 +8,7 @@ #include "ipc/ipc.h" #include "log/candidate_diag_log.h" #include "skin/candidate_skin_catalog.h" +#include "window/candidate_skin_layers.h" #include "window/candidate_skin_overlay.h" #include "utils/common_utils.h" #include "utils/ime_utils.h" @@ -34,6 +35,7 @@ #include #include +#include #include #include #include @@ -67,8 +69,7 @@ int AlignHostPixels(int value) } // Recompute half-screen DIP limits against `scale` when it differs from the -// monitor-derived scale, so clamping and window sizing stay on one scale -// (mirrors the webview path's rasterization-scale composition). +// monitor-derived scale, so clamping and window sizing stay on one scale. HalfScreenDipLimits ApplyScaleToHalfScreenLimits(POINT pt, FLOAT scale) { HalfScreenDipLimits limits = QueryHalfScreenDipLimitsForPoint(pt); @@ -151,8 +152,7 @@ struct CandSkinTokens constexpr float kTranslationPosAlpha = 0x80 / 255.0f; constexpr float kTranslationSeparatorAlpha = 0x73 / 255.0f; -// 内置皮肤的翻译配色取自 Notion 调色板(https://docs.super.so/notion-colors),与 -// ui-html/webview2/candwnd/skins//*.css 里的 .cand-translation 同值。词性用相邻的另一个色相再调淡, +// 内置皮肤的翻译配色取自 Notion 调色板(https://docs.super.so/notion-colors)。词性用相邻的另一个色相再调淡, // 和英文单词区分开;分隔符是单词色降不透明度。 void SetTranslationColors(CandSkinTokens &tokens, UINT rgb, UINT posRgb) { @@ -191,7 +191,6 @@ void ApplyPackageColors(const CandidateSkinCatalog::CandidateColors &colors, Can apply(colors.selectedText, tokens.rowTextSelected); apply(colors.selectedNumber, tokens.rowLabelSelected); // 包换了翻译色就不再用基础皮肤的词性/分隔符配色:普通行回到与翻译同色,选中行跟随 selected_translation。 - // 与 WebView2 端的 `color: inherit` 重置一致。 if (!colors.translation.empty()) { tokens.translationPos = D2D1::ColorF(0, 0.0f); @@ -249,6 +248,8 @@ constexpr float kShadowPadTop = 20.0f; constexpr float kShadowPadRight = 32.0f; constexpr float kShadowPadBottom = 40.0f; constexpr float kCandidateMinWidthDip = 160.0f; +// body(预编辑行、候选列表、竖排翻页行)各行之间的固定行距。 +constexpr float kBodyRowSpacingDip = 2.0f; msimeui::ImageStretch BackgroundStretch(const std::string &fit) { @@ -269,8 +270,7 @@ msimeui::HorizontalAlignment DecorationAlignment(const std::string &align) } // Below this fraction of the kept extent, the page really got smaller (fewer or // shorter candidates) and the card follows it; above it, the difference is a -// late addition coming or going and the card keeps its size. Mirrors -// kStickyShrinkRatio in the WebView2 candidate script. +// late addition coming or going and the card keeps its size. constexpr float kStickyCardShrinkRatio = 0.7f; float StickyCardExtent(float kept, float natural) @@ -327,6 +327,45 @@ D2D1_COLOR_F WithAlpha(D2D1_COLOR_F color, float alpha) return color; } +// 候选窗 HWND 上的动图帧定时器。宿主自己的定时器编号见 ime_windows_internal.h(2–14),这里避开它们。 +constexpr UINT_PTR kCandidateAnimationTimerId = 0x0A11; + +// 系统「显示动画」关闭(设置 → 辅助功能 → 视觉效果 → 动画效果)时只显示动图首帧,与浏览器的 +// prefers-reduced-motion 同一个开关。读不到时按开启处理。 +bool SystemAnimationsEnabled() +{ + BOOL enabled = TRUE; + if (!SystemParametersInfoW(SPI_GETCLIENTAREAANIMATION, 0, &enabled, 0)) + { + return true; + } + return enabled != FALSE; +} + +std::vector OverlayItems(const std::vector &layer, + const std::wstring &packageRoot) +{ + std::vector items; + for (const CandidateSkinCatalog::FreeDecoration &dec : layer) + { + CandidateSkinOverlay::Item item; + item.path = packageRoot + string_to_wstring(dec.image); + item.anchor = dec.anchor; + item.offsetXDip = static_cast(dec.offsetXDip); + item.offsetYDip = static_cast(dec.offsetYDip); + item.widthDip = static_cast(dec.widthDip); + item.heightDip = static_cast(dec.heightDip); + items.push_back(std::move(item)); + } + return items; +} + +msimeui::Thickness ToThickness(const CandidateSkinLayers::Edges &edges) +{ + return {static_cast(edges.left), static_cast(edges.top), static_cast(edges.right), + static_cast(edges.bottom)}; +} + } // namespace struct CandidatePresenter::Impl @@ -337,8 +376,10 @@ struct CandidatePresenter::Impl std::shared_ptr decoration; std::shared_ptr card; // 卡片与 v2 挂件层放在同一格里:挂件层测量为 0,格子仍由卡片决定,挂件按 anchor 画到卡片外沿。 + // 网格按子元素顺序绘制:下层挂件、卡片、上层挂件。 std::shared_ptr overlayGrid; - std::shared_ptr skinOverlay; + std::shared_ptr skinOverlayBack; + std::shared_ptr skinOverlayFront; // 装饰图与卡片同列:列宽即卡片宽,装饰图在列内按 align 对齐,对应 CSS 里 // .containerParent 为 fit-content、::before 相对它定位。 std::shared_ptr cardColumn; @@ -414,7 +455,7 @@ void CandidatePresenter::RebuildScene() impl_->list->SetOnItemActivated([this](size_t index) { CommitItem(index); }); impl_->list->SetOnContextMenu( [this](size_t index, const POINT &clientPoint) { ShowItemContextMenu(index, clientPoint); }); - impl_->body = std::make_shared(2.0f); + impl_->body = std::make_shared(kBodyRowSpacingDip); impl_->body->SetPadding({0.0f, 0.0f, 0.0f, 0.0f}); impl_->body->AddChild(impl_->preedit); impl_->pager.reset(); @@ -456,11 +497,15 @@ void CandidatePresenter::RebuildScene() impl_->overlayGrid = std::make_shared(); impl_->overlayGrid->AddRow(msimeui::GridLength{msimeui::GridUnitType::Auto, 1.0f}); impl_->overlayGrid->AddColumn(msimeui::GridLength{msimeui::GridUnitType::Auto, 1.0f}); + for (auto *overlay : {&impl_->skinOverlayBack, &impl_->skinOverlayFront}) + { + *overlay = std::make_shared(); + (*overlay)->SetHorizontalAlignment(msimeui::HorizontalAlignment::Stretch); + (*overlay)->SetVerticalAlignment(msimeui::VerticalAlignment::Stretch); + } + impl_->overlayGrid->AddChild(impl_->skinOverlayBack, 0, 0); impl_->overlayGrid->AddChild(impl_->card, 0, 0); - impl_->skinOverlay = std::make_shared(); - impl_->skinOverlay->SetHorizontalAlignment(msimeui::HorizontalAlignment::Stretch); - impl_->skinOverlay->SetVerticalAlignment(msimeui::VerticalAlignment::Stretch); - impl_->overlayGrid->AddChild(impl_->skinOverlay, 0, 0); + impl_->overlayGrid->AddChild(impl_->skinOverlayFront, 0, 0); impl_->cardColumn = std::make_shared(0.0f); impl_->cardColumn->SetHorizontalAlignment(msimeui::HorizontalAlignment::Leading); impl_->cardColumn->SetHorizontalContentAlignment(msimeui::HorizontalAlignment::Stretch); @@ -503,6 +548,11 @@ void CandidatePresenter::ApplySkin() return; } lastSkinFingerprint_ = skinKey; + // 换了皮肤:旧皮肤的动图不再续帧,新皮肤的动画从这一次显示重新起算。强制重载(重载代数变了) + // 也走到这里,丢掉已解码的图片,皮肤目录里换过的图才会重新读取。 + StopAnimationFrames(); + animationClockRunning_ = false; + impl_->resources.ClearImageCaches(); CandSkinTokens tokens; if (candLight) { @@ -515,7 +565,7 @@ void CandidatePresenter::ApplySkin() tokens.menuHover = ColorFromRgb(0xECECEC); } const std::wstring skinsRoot = AssetRoot() + L"\\skins"; - // 外部皮肤用声明支持当前布局与主题的那一份;不支持或读不出来就回退 fluent,和 WebView2 端同一条规则。 + // 外部皮肤用声明支持当前布局与主题的那一份;不支持或读不出来就回退 fluent。 const std::string candidateLayout = GetConfiguredCandidateWindowLayout(); const std::string candidateTheme = candLight ? "light" : "dark"; std::optional package; @@ -524,13 +574,10 @@ void CandidatePresenter::ApplySkin() package = CandidateSkinCatalog::LoadSupported(std::filesystem::path(skinsRoot), skinId, candidateLayout, candidateTheme); } - // 自定义包的几何取自它的 base 皮肤,与 WebView2 端注入 base 皮肤 CSS 一致;颜色随后由包覆盖。 + // 自定义包的几何取自它的 base 皮肤;颜色随后由包覆盖。 const std::string baseSkin = package ? package->base : (CandidateSkinCatalog::IsBuiltIn(skinId) ? skinId : std::string("fluent")); - // Built-in skin palettes mirror ui-html/webview2/candwnd/skins//; - // the WebView2 CSS is the reference for both the light and dark values. - // 横排时高亮内缩于外框,首项、尾项落在卡片四角上的那几个角与卡片同 R,其余角保持 - // itemRadius,对应 CSS 横排的 .pinyin + .row-wrapper / .last-visible 规则。 + // 横排时高亮内缩于外框,首项、尾项落在卡片四角上的那几个角与卡片同 R,其余角保持 itemRadius。 const bool horizontalLayout = candidateLayout == "horizontal"; // 翻页箭头改变的是场景结构(竖排多一行、横排多一列),开关或排列方式变了就整棵重建, // 再往下套用配色与尺寸。重建会清掉指纹,这里补回去。 @@ -724,7 +771,7 @@ void CandidatePresenter::ApplySkin() } else if (baseSkin == "microsoft") { - // 几何随字号缩放,对应 CSS 里以 em 写的尺寸(ui-html/webview2/candwnd/skins/microsoft/)。 + // 几何随字号缩放(按 em 比例)。 tokens.borderWidth = 1.0f; tokens.radius = 8.0f; tokens.containerPad = std::round(fontSize * 0.3f); @@ -771,6 +818,22 @@ void CandidatePresenter::ApplySkin() tokens.menuHover = ColorFromRgb(0x383838); } } + else if (CandidateSkinCatalog::IsCanvasBase(baseSkin)) + { + // 全贴图基底:面板透明、无描边、直角,外观交给九宫格贴图与挂件(阴影在下面按皮肤决定)。 + // 文字与高亮取不依赖底色的中性色:深色主题近白字配白色半透明高亮,浅色主题近黑字配黑色半透明高亮, + // 叠在大多数贴图上都可读;皮肤用 [candidate.dark] / [candidate.light] 覆盖。 + tokens.borderWidth = 0.0f; + tokens.radius = 0.0f; + tokens.containerPad = 5.0f; + tokens.itemRadius = 4.0f; + tokens.number = candLight ? D2D1::ColorF(26.0f / 255.0f, 26.0f / 255.0f, 26.0f / 255.0f, 0.6f) + : ColorFromRgb(0xF2F2F2, 0.62f); + tokens.selected = candLight ? D2D1::ColorF(0, 0.10f) : D2D1::ColorF(1.0f, 1.0f, 1.0f, 0.16f); + tokens.hover = candLight ? D2D1::ColorF(0, 0.06f) : D2D1::ColorF(1.0f, 1.0f, 1.0f, 0.09f); + // 翻译同 fluent:单词 Notion Purple,词性 Notion Pink。 + SetTranslationColors(tokens, candLight ? 0x6940A5 : 0x9A6DD7, candLight ? 0xAD1A72 : 0xE255A1); + } if (package) { @@ -797,16 +860,22 @@ void CandidatePresenter::ApplySkin() tokens.surface = palette.surface; tokens.border = palette.border; tokens.text = palette.text; - // 阴影跟皮肤走,与 WebView2 端同一参照:四套内置皮肤与自定义包共用的皮肤 CSS - // (ui-html/webview2/candwnd/skins/*/horizontal_*.css)带同一对 box-shadow—— + // 阴影跟皮肤走:四套内置皮肤与自定义包共用同一对阴影—— // light `8px 10px 24px rgba(0,0,0,.18), 2px 3px 8px rgba(0,0,0,.10)`,dark α .34/.22; - // σ = blur ÷ 2。默认皮肤(webview 端只有 body HTML,无 box-shadow)直接关阴影。 + // σ = blur ÷ 2。默认皮肤直接关阴影。 + // v2 面板贴图:有它时卡片自己的底色、描边、圆角和阴影都让给贴图(同 CSS border-image)。 + const bool panelImage = + package && package->schemaVersion >= 2 && package->borderImage && !package->borderImage->source.empty(); + const bool canvasBase = CandidateSkinCatalog::IsCanvasBase(baseSkin); + skinAnimates_ = package && package->schemaVersion >= 2; if (impl_->card) { const std::string shadow = package ? package->shadow : std::string{}; - if (shadow != "none") + // canvas 默认没有阴影,皮肤写了 soft / strong 才有;面板贴图在时一律不画。 + const bool shadowOn = shadow != "none" && !(canvasBase && shadow.empty()) && !panelImage; + if (shadowOn) { - // 外部皮肤的 shadow = soft / strong 按同一系数缩放两层不透明度,与 WebView2 端一致。 + // 外部皮肤的 shadow = soft / strong 按同一系数缩放两层不透明度。 const float scale = ShadowAlphaScale(shadow); const auto alpha = [scale](float value) { return (std::min)(value * scale, 1.0f); }; const std::vector shadowPasses = { @@ -840,7 +909,7 @@ void CandidatePresenter::ApplySkin() const float preeditSize = static_cast((std::max)(12, GetConfiguredCandidateWindowPreeditFontSize())); msimeui::CandidateList::Appearance appearance; appearance.fontFamily = string_to_wstring(ResolveSystemFontFamilyForCss(GetConfiguredCandidateEnglishFont())); - // 皮肤的 font_family 排在最前,用户配置的字体整体退为回退,与 WebView2 端的 font-family 列表同序。 + // 皮肤的 font_family 排在最前,用户配置的字体整体退为回退。 if (package && !package->fontFamily.empty()) { appearance.fallbackFontFamilies.push_back(appearance.fontFamily); @@ -879,10 +948,19 @@ void CandidatePresenter::ApplySkin() // 隐藏的预编辑行高度为 0,但 body 仍在它和列表之间留 2px 行距;贴角皮肤把它抵掉, // 让首项贴着卡片的内容顶边(秋桂四边内边距一致,杨柳青高亮直抵卡片顶边)。 if (preeditHidden) + { impl_->preedit->SetMargin( {0.0f, 0.0f, 0.0f, tokens.outerItemRadius > 0.0f || tokens.trimHiddenPreeditGap ? -2.0f : 0.0f}); + } else - impl_->preedit->SetMargin(tokens.preeditMargin); + { + msimeui::Thickness preeditMargin = tokens.preeditMargin; + // v2 的 line_gap_dip 只是预编辑行与候选之间的距离(原 CSS 的 .row.pinyin margin-bottom), + // 列表与翻页箭头之间仍是 body 的 2 DIP 行距,所以落在预编辑行的下外边距上,再抵掉那 2 DIP。 + if (package && package->schemaVersion >= 2 && package->lineGapDip) + preeditMargin.bottom = static_cast(*package->lineGapDip) - kBodyRowSpacingDip; + impl_->preedit->SetMargin(preeditMargin); + } appearance.textColor = palette.candidateText; appearance.labelColor = tokens.number; appearance.annotationColor = palette.candidateText; @@ -982,7 +1060,7 @@ void CandidatePresenter::ApplySkin() ? msimeui::HorizontalAlignment::Stretch : msimeui::HorizontalAlignment::Leading); - // 装饰图与 WebView2 端的 .containerParent::before 同一几何:宽 width_dip、高 top_inset_dip 的盒子 + // 装饰图是宽 width_dip、高 top_inset_dip 的盒子, // 底边贴着卡片顶边、不与卡片重叠,按 align 贴卡片左/中/右,图片在盒内等比居中(contain)。 decorationTopDip_ = 0.0f; decorationWidthDip_ = 0.0f; @@ -1034,32 +1112,16 @@ void CandidatePresenter::ApplySkin() impl_->frame->SetPadding({framePadLeft, framePadTop, framePadRight, framePadBottom}); } - // v2 自由挂件交给 overlay 层绘制;它测量为 0,不参与卡片布局。 - if (impl_->skinOverlay) + // v2 自由挂件交给两层 overlay 绘制,都测量为 0、不参与卡片布局:z_index 低于 kDecorationFrontZIndex 的 + // 在卡片之前绘制(面板之下),其余在卡片之后(面板与文字之上)。同层按 z_index、再按声明顺序叠放。 { - std::vector items; - if (package) - { - for (const CandidateSkinCatalog::FreeDecoration &dec : package->freeDecorations) - { - CandidateSkinOverlay::Item item; - item.path = packageRoot + string_to_wstring(dec.image); - item.anchor = dec.anchor; - item.offsetXDip = static_cast(dec.offsetXDip); - item.offsetYDip = static_cast(dec.offsetYDip); - item.widthDip = static_cast(dec.widthDip); - item.heightDip = static_cast(dec.heightDip); - items.push_back(std::move(item)); - } - } - impl_->skinOverlay->SetItems(std::move(items)); - } - // v2 的 line_gap_dip 落到预编辑行与候选列表之间的间距上。 - // TODO(v2 皮肤): 暂定 WebView2 独占。SetSpacing 作用于 body 的全部子元素,竖排时列表与翻页栏之间也会被拉开, - // WebView2 只加在 .row.pinyin 下方。 - if (impl_->body) - { - impl_->body->SetSpacing(package && package->lineGapDip ? static_cast(*package->lineGapDip) : 2.0f); + CandidateSkinLayers::Layers layers; + if (package && package->schemaVersion >= 2) + layers = CandidateSkinLayers::SplitDecorations(package->freeDecorations); + if (impl_->skinOverlayBack) + impl_->skinOverlayBack->SetItems(OverlayItems(layers.below, packageRoot)); + if (impl_->skinOverlayFront) + impl_->skinOverlayFront->SetItems(OverlayItems(layers.above, packageRoot)); } msimeui::Brush brush; @@ -1068,47 +1130,35 @@ void CandidatePresenter::ApplySkin() brush.strokeWidth = tokens.borderWidth; brush.radiusX = tokens.radius; brush.radiusY = tokens.radius; + if (panelImage) + { + brush.fill = D2D1::ColorF(0, 0.0f); + brush.stroke = D2D1::ColorF(0, 0.0f); + brush.strokeWidth = 0.0f; + brush.radiusX = 0.0f; + brush.radiusY = 0.0f; + } if (impl_->card) { impl_->card->SetBrush(brush); - if (package && package->contentInsetsDip) + if (package && package->schemaVersion >= 2) { - // v2 的 content_insets_dip 从面板外缘量起,D2D 侧直接落成卡片内边距(这里没有 CSS 的边框内缘概念)。 - const CandidateSkinCatalog::InsetsDip &insets = *package->contentInsetsDip; - impl_->card->SetPadding(msimeui::Thickness{static_cast(insets.left), static_cast(insets.top), - static_cast(insets.right), - static_cast(insets.bottom)}); + // 内容从面板外缘量起:content_insets_dip,或九宫格边宽加基底内边距,不会压到边框贴图上。 + impl_->card->SetPadding( + ToThickness(CandidateSkinLayers::ResolveContentPadding(*package, tokens.containerPad))); } else { impl_->card->SetPadding(tokens.containerPad); } impl_->card->SetMinWidth(cardMinWidthDip_); - if (package && package->borderImage && !package->borderImage->source.empty()) + if (panelImage) { - // v2 九宫格:四边原样、其余拉伸。切片按 1/2/4 值展开成「上右下左」。 - // TODO(v2 皮肤): 暂定 WebView2 独占。D2D 尚未处理 width_dip 与 fill = false,九宫格仍叠在卡片底色、 - // 描边与阴影之上并被卡片圆角裁剪;WebView2 会把这些清成透明、圆角归零。 - const CandidateSkinCatalog::BorderImage &bi = *package->borderImage; - const std::vector &values = bi.stretchDip.empty() ? bi.sliceDip : bi.stretchDip; - const auto expand4 = [](const std::vector &v, float out[4]) { - if (v.size() == 1) - out[0] = out[1] = out[2] = out[3] = static_cast(v[0]); - else if (v.size() == 2) - { - out[0] = out[2] = static_cast(v[0]); - out[1] = out[3] = static_cast(v[1]); - } - else - { - for (size_t i = 0; i < 4; ++i) - out[i] = i < v.size() ? static_cast(v[i]) : 0.0f; - } - }; - float slice[4] = {0.0f, 0.0f, 0.0f, 0.0f}; - expand4(values, slice); - impl_->card->SetBackgroundImageNineSlice(packageRoot + string_to_wstring(bi.source), slice[3], slice[0], - slice[1], slice[2], 1.0f); + // CSS border-image 语义:切片以源图像素计,边宽以 DIP 计,fill 决定是否画中心块。 + const CandidateSkinLayers::NineSlice nine = CandidateSkinLayers::ResolveNineSlice(*package->borderImage); + impl_->card->SetBackgroundNineSlice( + packageRoot + string_to_wstring(package->borderImage->source), + msimeui::NineSlice{ToThickness(nine.slice), ToThickness(nine.width), nine.fill}, 1.0f); } else { @@ -1133,7 +1183,7 @@ std::uint64_t CandidatePresenter::FillItemsFromUi() item.annotation = string_to_wstring(view.annotation); if (translationsEnabled) { - // 逐段转成 UTF-16 再拼起来,区间按拼出来的串计,与 WebView2 的 .cand-pos / .cand-sep 同一切法。 + // 逐段转成 UTF-16 再拼起来,区间按拼出来的串计。 for (const auto &segment : FanyImeIpc::SegmentTranslationGloss(view.translation)) { const std::wstring text = string_to_wstring(segment.text); @@ -1584,6 +1634,7 @@ void CandidatePresenter::ShowFromGlobalState(POINT caret) if (caret.y == Global::INVALID_Y) { CloseContextMenu(false); + StopAnimationFrames(); SetCandidateHostCloaked(true); ::is_global_wnd_cand_shown = true; Global::SetCandidateWindowRenderedVisible(true); @@ -1592,6 +1643,14 @@ void CandidatePresenter::ShowFromGlobalState(POINT caret) } CloseContextMenu(false); ApplySkin(); + // 减少动画的系统开关可能随时改,每次显示都重读;v1 皮肤从不播放。动画从候选窗出现时起算, + // 同一次显示里的按键不会让它重头开始。 + impl_->resources.SetAnimationsEnabled(skinAnimates_ && SystemAnimationsEnabled()); + if (!animationClockRunning_) + { + impl_->resources.SetAnimationEpoch(msimeui::DeviceResources::AnimationClock::now()); + animationClockRunning_ = true; + } std::wstring preedit; size_t caretIndex = 0; if (GetConfiguredCandidateWindowPreeditStyle() != "empty") @@ -1697,6 +1756,8 @@ void CandidatePresenter::ShowFromGlobalState(POINT caret) PlaceAndShow(caret, widthDip, heightDip, cardLeftDip, cardTopDip, scale); ::is_global_wnd_cand_shown = true; Global::SetCandidateWindowRenderedVisible(true); + // PlaceAndShow presented while the host was still being placed; it is placed and shown only now. + ScheduleAnimationFrame(); // PlaceAndShow paints synchronously, so the page is on screen now. Echo the generation of the // snapshot that was actually rendered (not the latest published one) so a pending digit/space // selection can settle against the same page the user is looking at. @@ -1710,6 +1771,8 @@ void CandidatePresenter::Hide() return; } CloseContextMenu(false); + StopAnimationFrames(); + animationClockRunning_ = false; ::is_global_wnd_cand_shown = false; Global::SetCandidateWindowRenderedVisible(false); hoverArmed_ = false; @@ -1759,6 +1822,8 @@ void CandidatePresenter::Present() { return; } + // Samples the animation clock for this frame and starts the next-frame deadline over. + impl_->resources.BeginFrame(); target->BeginDraw(); target->Clear(D2D1::ColorF(0, 0.0f)); if (msimeui::Scene *scene = impl_->window->GetScene()) @@ -1784,6 +1849,39 @@ void CandidatePresenter::Present() return; } impl_->resources.Present(); + ScheduleAnimationFrame(); +} + +void CandidatePresenter::ScheduleAnimationFrame() +{ + if (!hwnd_ || !impl_ || !impl_->window) + { + return; + } + const msimeui::Scene *scene = impl_->window->GetScene(); + const auto deadline = scene ? scene->GetNextFrameDeadline() : std::nullopt; + // While placing, PlaceAndShow presents and schedules once the host is visible; a hidden or + // parked host has nothing on screen to animate. + if (!deadline || placingHost_ || !::is_global_wnd_cand_shown || !IsWindowVisible(hwnd_)) + { + StopAnimationFrames(); + return; + } + const auto wait = + std::chrono::ceil(*deadline - msimeui::DeviceResources::AnimationClock::now()); + const UINT delay = static_cast((std::clamp)(wait.count(), static_cast(USER_TIMER_MINIMUM), + static_cast(USER_TIMER_MAXIMUM))); + // One shot per frame, replacing any pending one. A little slack lets Windows batch the wake-up + // with others without visibly changing the frame timing. + SetCoalescableTimer(hwnd_, kCandidateAnimationTimerId, delay, nullptr, (std::min)(delay / 10U, 8U)); +} + +void CandidatePresenter::StopAnimationFrames() +{ + if (hwnd_) + { + KillTimer(hwnd_, kCandidateAnimationTimerId); + } } void CandidatePresenter::PresentEmptyFrame() @@ -1841,6 +1939,18 @@ bool CandidatePresenter::HandleMessage(UINT message, WPARAM wParam, LPARAM lPara { case WM_ERASEBKGND: return true; + case WM_TIMER: + if (wParam != kCandidateAnimationTimerId) + { + return false; + } + // One-shot: the Present below schedules the next frame if anything still animates. + StopAnimationFrames(); + if (::is_global_wnd_cand_shown && !placingHost_) + { + Present(); + } + return true; case WM_PAINT: { PAINTSTRUCT ps{}; BeginPaint(hwnd_, &ps); diff --git a/server/src/window/candidate_presenter.h b/server/src/window/candidate_presenter.h index 0a7ff5a2..e4629c59 100644 --- a/server/src/window/candidate_presenter.h +++ b/server/src/window/candidate_presenter.h @@ -47,6 +47,10 @@ class CandidatePresenter void CloseFixSubmenu(); void ExpandHostForMenu(POINT clientPoint); void RestoreHostAfterMenu(); + // 动图的帧时钟:Present 之后按场景给出的下一次换帧时刻排一个一次性定时器,到点再 Present。 + // 只在候选窗显示时排;隐藏、换皮肤、窗口销毁时停。 + void ScheduleAnimationFrame(); + void StopAnimationFrames(); struct Impl; std::unique_ptr impl_; @@ -64,6 +68,10 @@ class CandidatePresenter float cardMinWidthDip_ = 160.0f; // 皮肤开了 fit_content:没有默认的 160,卡片宽度也不在同一输入里只增不减。 bool fitContent_ = false; + // 只有 v2(canvas)皮肤播放动图;v1 皮肤的图片保持原先的静态首帧。 + bool skinAnimates_ = false; + // 动画从候选窗这一次显示起算;隐藏或换皮肤后,下一次显示重新从第一帧开始。 + bool animationClockRunning_ = false; std::string lastSkinFingerprint_; int lastHostWidthPx_ = 0; int lastHostHeightPx_ = 0; diff --git a/server/src/window/candidate_skin_layers.cpp b/server/src/window/candidate_skin_layers.cpp new file mode 100644 index 00000000..bc0d1545 --- /dev/null +++ b/server/src/window/candidate_skin_layers.cpp @@ -0,0 +1,67 @@ +#include "window/candidate_skin_layers.h" + +#include + +namespace CandidateSkinLayers +{ +Edges ExpandBox(const std::vector &values) +{ + Edges edges; + if (values.size() == 1) + { + edges = {values[0], values[0], values[0], values[0]}; + } + else if (values.size() == 2) + { + edges = {values[0], values[1], values[0], values[1]}; + } + else if (values.size() == 4) + { + edges = {values[0], values[1], values[2], values[3]}; + } + return edges; +} + +NineSlice ResolveNineSlice(const CandidateSkinCatalog::BorderImage &borderImage) +{ + NineSlice result; + const std::vector &slice = borderImage.stretchDip.empty() ? borderImage.sliceDip : borderImage.stretchDip; + result.slice = ExpandBox(slice); + result.width = borderImage.widthDip.empty() ? result.slice : ExpandBox(borderImage.widthDip); + result.fill = borderImage.fill; + return result; +} + +Edges ResolveContentPadding(const CandidateSkinCatalog::Package &package, double basePadding) +{ + Edges border; + if (package.borderImage && !package.borderImage->source.empty()) + { + border = ResolveNineSlice(*package.borderImage).width; + } + if (package.contentInsetsDip) + { + const CandidateSkinCatalog::InsetsDip &insets = *package.contentInsetsDip; + return {(std::max)(insets.top, border.top), (std::max)(insets.right, border.right), + (std::max)(insets.bottom, border.bottom), (std::max)(insets.left, border.left)}; + } + return {border.top + basePadding, border.right + basePadding, border.bottom + basePadding, + border.left + basePadding}; +} + +Layers SplitDecorations(const std::vector &decorations) +{ + std::vector sorted = decorations; + std::stable_sort(sorted.begin(), sorted.end(), + [](const CandidateSkinCatalog::FreeDecoration &a, const CandidateSkinCatalog::FreeDecoration &b) { + return a.zIndex < b.zIndex; + }); + Layers layers; + for (auto &decoration : sorted) + { + (decoration.zIndex < CandidateSkinCatalog::kDecorationFrontZIndex ? layers.below : layers.above) + .push_back(std::move(decoration)); + } + return layers; +} +} // namespace CandidateSkinLayers diff --git a/server/src/window/candidate_skin_layers.h b/server/src/window/candidate_skin_layers.h new file mode 100644 index 00000000..8e70ada6 --- /dev/null +++ b/server/src/window/candidate_skin_layers.h @@ -0,0 +1,48 @@ +#pragma once + +#include "skin/candidate_skin_catalog.h" + +#include + +// v2(canvas)皮肤在候选窗里的几何:九宫格切片与边宽、内容内边距、挂件分层。只做数值换算, +// 不碰窗口与渲染,D2D 候选窗和设置页预览共用这一份规则。 +namespace CandidateSkinLayers +{ +struct Edges +{ + double top = 0.0; + double right = 0.0; + double bottom = 0.0; + double left = 0.0; +}; + +// CSS 四值简写:1 个值四边相同,2 个值是「上下 左右」,4 个值是「上右下左」。其余个数按缺省 0。 +Edges ExpandBox(const std::vector &values); + +struct NineSlice +{ + // 源图像素:stretch_dip 给出时用它,否则用 slice_dip。 + Edges slice; + // 屏幕上的边宽(DIP):width_dip 给出时用它,否则取切片数值当 DIP(CSS border-image-width 缺省即 border-width, + // v2 规定 border-width 等于切片数值)。 + Edges width; + bool fill = true; +}; + +NineSlice ResolveNineSlice(const CandidateSkinCatalog::BorderImage &borderImage); + +// 面板外缘到内容的距离(DIP)。写了 content_insets_dip 时就是它,但不小于九宫格该边的边宽(文字不压在边框上, +// 与 CSS 的 border + padding 一致);没写时是九宫格边宽加上基底自己的内边距。 +Edges ResolveContentPadding(const CandidateSkinCatalog::Package &package, double basePadding); + +struct Layers +{ + // z_index < kDecorationFrontZIndex,画在面板之下。 + std::vector below; + // 其余,画在面板与候选文字之上。 + std::vector above; +}; + +// 按 z_index 升序稳定排序(同层保持声明顺序),再以 kDecorationFrontZIndex 为界分成两层。 +Layers SplitDecorations(const std::vector &decorations); +} // namespace CandidateSkinLayers diff --git a/server/src/window/candidate_skin_overlay.cpp b/server/src/window/candidate_skin_overlay.cpp index 2ba099ba..49ebc239 100644 --- a/server/src/window/candidate_skin_overlay.cpp +++ b/server/src/window/candidate_skin_overlay.cpp @@ -27,7 +27,6 @@ bool CandidateSkinOverlay::HitTest(const msimeui::PointF & /*point*/) const return false; } -// TODO(v2 皮肤): v2 自由挂件暂定 WebView2 独占,D2D 这里尚未按 z_index 分层,全部按声明顺序画在卡片之上。 void CandidateSkinOverlay::Render(msimeui::DeviceResources &deviceResources) { if (items_.empty() || bounds_.width <= 0.0f || bounds_.height <= 0.0f) @@ -41,8 +40,9 @@ void CandidateSkinOverlay::Render(msimeui::DeviceResources &deviceResources) } for (const Item &item : items_) { + // 动图按动画时钟取当前帧,并把下一次换帧时间报给宿主。 D2D1_SIZE_F sourceSize = {}; - ID2D1Bitmap *bitmap = deviceResources.GetBitmapFromFile(item.path, &sourceSize); + ID2D1Bitmap *bitmap = deviceResources.GetImageFrame(item.path, &sourceSize); if (!bitmap || sourceSize.width <= 0.0f || sourceSize.height <= 0.0f) { continue; @@ -95,7 +95,13 @@ void CandidateSkinOverlay::Render(msimeui::DeviceResources &deviceResources) } x += item.offsetXDip; y += item.offsetYDip; - const D2D1_RECT_F destination = D2D1::RectF(x, y, x + width, y + height); + // 图片在 width × height 的框里等比缩放、居中(CSS background-size: contain),比例不同也不变形。 + const float scale = (std::min)(width / sourceSize.width, height / sourceSize.height); + const float drawWidth = sourceSize.width * scale; + const float drawHeight = sourceSize.height * scale; + const float left = x + (width - drawWidth) * 0.5f; + const float top = y + (height - drawHeight) * 0.5f; + const D2D1_RECT_F destination = D2D1::RectF(left, top, left + drawWidth, top + drawHeight); const D2D1_RECT_F source = D2D1::RectF(0.0f, 0.0f, sourceSize.width, sourceSize.height); target->DrawBitmap(bitmap, destination, 1.0f, D2D1_BITMAP_INTERPOLATION_MODE_LINEAR, &source); } diff --git a/server/src/window/candidate_skin_overlay.h b/server/src/window/candidate_skin_overlay.h index 547458de..ae25ec20 100644 --- a/server/src/window/candidate_skin_overlay.h +++ b/server/src/window/candidate_skin_overlay.h @@ -12,6 +12,7 @@ class DeviceResources; // v2 皮肤的自由挂件层。它不参与布局(测量恒为 0),由父级摆到卡片那一格, // 内部按 anchor + offset 把挂件画到卡片边界之外,所以窗口必须按 outer_insets_dip 多留出空间。 +// 候选窗用两层:z_index 低于 kDecorationFrontZIndex 的一层排在卡片之前(面板之下),其余一层排在卡片之后。 class CandidateSkinOverlay : public msimeui::Visual { public: @@ -27,12 +28,13 @@ class CandidateSkinOverlay : public msimeui::Visual float heightDip = 0.0f; }; + // 按绘制顺序给出,先给的在下面。 void SetItems(std::vector items); msimeui::SizeF Measure(const msimeui::SizeF &availableSize) override; void Arrange(const msimeui::RectF &finalRect) override; void Render(msimeui::DeviceResources &deviceResources) override; - // 挂件纯装饰,叠在卡片那一格上方,点击必须穿透给候选。 + // 挂件纯装饰,叠在卡片那一格上,点击必须穿透给候选。 bool HitTest(const msimeui::PointF &point) const override; private: diff --git a/server/src/window/candidate_skin_palette.cpp b/server/src/window/candidate_skin_palette.cpp index c37b4797..a14aea31 100644 --- a/server/src/window/candidate_skin_palette.cpp +++ b/server/src/window/candidate_skin_palette.cpp @@ -128,6 +128,14 @@ CandidateSkinPalette ResolveCandidateSkinPalette(const std::string &skinId, bool : CandidateSkinPalette{CandidateColorFromRgb(0x2C2C2C), CandidateColorFromRgb(0x1C1C1C), CandidateColorFromRgb(0xFFFFFF)}; } + else if (CandidateSkinCatalog::IsCanvasBase(paletteSkinId)) + { + // 全贴图基底:面板与描边透明,外观交给九宫格贴图。透明色仍带上 fluent 的底色 RGB, + // GDI 那类不能透明的使用方压平时得到的是可读的底色而不是黑色。文字取高对比的近白 / 近黑。 + palette.surface = CandidateColorFromRgb(light ? 0xFFFFFF : 0x202020, 0.0f); + palette.border = D2D1::ColorF(0, 0.0f); + palette.text = CandidateColorFromRgb(light ? 0x1A1A1A : 0xF2F2F2); + } if (packageColors) { if (!packageColors->surface.empty()) diff --git a/server/src/window/candidate_skin_palette.h b/server/src/window/candidate_skin_palette.h index bfe4bc0c..78ff29c1 100644 --- a/server/src/window/candidate_skin_palette.h +++ b/server/src/window/candidate_skin_palette.h @@ -17,6 +17,7 @@ struct CandidateSkinPalette }; // 基础皮肤的候选项高亮圆角,对应各皮肤 CSS 的 .cand border-radius;D2D 的 CandSkinTokens 用同一组值。 +// canvas 基底沿用 fluent 的 4,皮肤可用 item_corner_radius_dip 改。 float CandidateSkinBaseItemRadiusDip(const std::string &baseSkinId); D2D1_COLOR_F CandidateColorFromRgb(UINT rgb, float alpha = 1.0f); diff --git a/server/tests/CMakeLists.txt b/server/tests/CMakeLists.txt index 3552385f..5b6567d8 100644 --- a/server/tests/CMakeLists.txt +++ b/server/tests/CMakeLists.txt @@ -96,6 +96,8 @@ set(TEST_SOURCE_FILES "${CMAKE_CURRENT_SOURCE_DIR}/src/test_non_ascii_path_contract.cpp" "${CMAKE_SOURCE_DIR}/src/skin/candidate_skin_catalog.cpp" "${CMAKE_SOURCE_DIR}/src/window/candidate_skin_palette.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_skin_layers.cpp" + "${CMAKE_SOURCE_DIR}/src/window/candidate_skin_layers.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_floating_toolbar_skin.cpp" "${CMAKE_SOURCE_DIR}/src/window/floating_toolbar_skin.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_system_font_family.cpp" diff --git a/server/tests/src/test_candidate_skin_catalog_bases.cpp b/server/tests/src/test_candidate_skin_catalog_bases.cpp index ff7b8a12..eeda8a42 100644 --- a/server/tests/src/test_candidate_skin_catalog_bases.cpp +++ b/server/tests/src/test_candidate_skin_catalog_bases.cpp @@ -87,7 +87,9 @@ TEST_CASE(candidate_skin_shared_fixed_bases_inherit_candidate_menu_and_toolbar_c const auto package = CandidateSkinCatalog::Load(fixture.root, "sample"); REQUIRE(package.has_value()); REQUIRE_EQ(package->base, std::string("fluent")); - REQUIRE_EQ(package->schemaVersion, schema); + // schema_version 2 needs the canvas base; on a shared base it loads as 1 with a notice. + REQUIRE_EQ(package->schemaVersion, 1); + REQUIRE_EQ(package->notices.size(), size_t(schema == 2 ? 1 : 0)); REQUIRE_EQ(package->themes, std::vector({entry.theme})); const bool light = std::string(entry.theme) == "light"; const auto &colors = light ? package->light : package->dark; diff --git a/server/tests/src/test_candidate_skin_catalog_colors.cpp b/server/tests/src/test_candidate_skin_catalog_colors.cpp index 034877de..aa08b5e2 100644 --- a/server/tests/src/test_candidate_skin_catalog_colors.cpp +++ b/server/tests/src/test_candidate_skin_catalog_colors.cpp @@ -115,8 +115,8 @@ translation = 42 std::filesystem::remove_all(root, ec); } -// Candidate colours are pasted into the WebView2 stylesheet, so a value that could close the declaration or the -// rule it lands in must reject the whole skin instead of rewriting the candidate window. +// Candidate colours follow CSS colour syntax shared by every platform's skin.toml, so a value that could close a +// declaration or rule must reject the whole skin. TEST_CASE(candidate_skin_catalog_rejects_candidate_color_that_escapes_css) { const auto root = WriteSkin(L"inject", R"( diff --git a/server/tests/src/test_candidate_skin_catalog_images.cpp b/server/tests/src/test_candidate_skin_catalog_images.cpp index 4d642bfd..6955e5ec 100644 --- a/server/tests/src/test_candidate_skin_catalog_images.cpp +++ b/server/tests/src/test_candidate_skin_catalog_images.cpp @@ -4,10 +4,12 @@ #include +#include #include #include #include #include +#include namespace { @@ -187,7 +189,7 @@ TEST_CASE(candidate_skin_catalog_rejects_invalid_toolbar_tables) REQUIRE(LoadFails(L"tb-not-table", window + "[[toolbar]]\n")); REQUIRE(LoadFails(L"tb-theme-not-table", window + "[toolbar]\ndark = \"pink\"\n")); REQUIRE(LoadFails(L"tb-color-type", window + "[toolbar.dark]\nhandle = 7\n")); - // Toolbar colours are pasted into the WebView2 page's CSS, so anything that could end the declaration is refused. + // Toolbar colours use the same CSS colour rules, so anything that could end a declaration is refused. REQUIRE(LoadFails(L"tb-color-inject", window + "[toolbar.light]\nicon = \"red; } body { display: none\"\n")); REQUIRE(LoadFails(L"tb-radius", window + "[toolbar]\ncorner_radius_dip = 40\n")); REQUIRE(LoadFails(L"tb-radius-type", window + "[toolbar]\ncorner_radius_dip = \"8px\"\n")); @@ -213,7 +215,7 @@ TEST_CASE(candidate_skin_catalog_reads_v2_features) id = "art" name = "Art" version = "2.0.0" -base = "fluent" +base = "canvas" [supports] layouts = ["horizontal"] @@ -305,7 +307,7 @@ TEST_CASE(candidate_skin_catalog_defaults_optional_decoration_keys) id = "art" name = "Art" version = "2.0.0" -base = "fluent" +base = "canvas" [supports] layouts = ["horizontal"] @@ -329,3 +331,197 @@ height_dip = 30 fs::remove_all(skins_root, ec); } + +namespace +{ +// A schema 2 skin with the given base and [candidate_window] body, plus the two images it may reference. +std::filesystem::path WriteV2Skin(const std::wstring &leaf, const std::string &base, const std::string &window, + int schema = 2) +{ + namespace fs = std::filesystem; + const fs::path skins_root = + fs::temp_directory_path() / (L"msime-skin-canvas-" + std::to_wstring(GetCurrentProcessId())) / leaf / L"skins"; + std::error_code ec; + fs::remove_all(skins_root, ec); + fs::create_directories(skins_root / L"art" / L"assets", ec); + REQUIRE(!ec); + for (const wchar_t *image : {L"assets\\panel.png", L"assets\\mascot.apng", L"assets\\spark.GIF", + L"assets\\leaf.webp", L"assets\\photo.jpg"}) + { + std::ofstream file(skins_root / L"art" / image, std::ios::binary | std::ios::trunc); + REQUIRE(file.is_open()); + } + std::ofstream manifest(skins_root / L"art" / L"skin.toml", std::ios::binary | std::ios::trunc); + REQUIRE(manifest.is_open()); + manifest << "schema_version = " << schema << "\nid = \"art\"\nname = \"Art\"\nversion = \"2.0.0\"\nbase = \"" + << base << "\"\n\n[supports]\nlayouts = [\"horizontal\", \"vertical\"]\nthemes = [\"dark\", \"light\"]\n\n" + << "[candidate_window]\n" + << window; + return skins_root; +} + +constexpr const char *kPanel = R"toml( +[candidate_window.border_image] +source = "assets/panel.png" +slice_dip = [12] +)toml"; + +bool V2LoadFails(const std::wstring &leaf, const std::string &window) +{ + const auto root = WriteV2Skin(leaf, "canvas", window); + std::string error; + const bool failed = !CandidateSkinCatalog::Load(root, "art", &error).has_value() && !error.empty(); + RemoveSkin(root); + return failed; +} +} // namespace + +TEST_CASE(candidate_skin_catalog_canvas_is_a_base_only) +{ + REQUIRE(CandidateSkinCatalog::IsCanvasBase("canvas")); + REQUIRE(!CandidateSkinCatalog::IsCanvasBase("fluent")); + // It cannot be picked on its own: it is not one of the built-in skins. + REQUIRE(!CandidateSkinCatalog::IsBuiltIn("canvas")); + const auto &ids = CandidateSkinCatalog::BuiltInIds(); + REQUIRE(std::find(ids.begin(), ids.end(), std::string("canvas")) == ids.end()); + + const auto root = WriteV2Skin(L"canvas-ok", "canvas", kPanel); + std::string error; + const auto package = CandidateSkinCatalog::Load(root, "art", &error); + REQUIRE(error.empty()); + REQUIRE(package.has_value()); + REQUIRE_EQ(package->base, std::string("canvas")); + REQUIRE_EQ(package->schemaVersion, 2); + REQUIRE(package->notices.empty()); + REQUIRE(package->borderImage.has_value()); + REQUIRE_EQ(package->borderImage->sliceDip, std::vector({12.0})); + REQUIRE(package->borderImage->widthDip.empty()); + REQUIRE(package->borderImage->fill); + RemoveSkin(root); +} + +TEST_CASE(candidate_skin_catalog_v1_cannot_use_the_canvas_base) +{ + const auto root = WriteV2Skin(L"canvas-v1", "canvas", "", 1); + std::string error; + REQUIRE(!CandidateSkinCatalog::Load(root, "art", &error).has_value()); + REQUIRE(!error.empty()); + const auto catalog = CandidateSkinCatalog::Scan(root); + REQUIRE(catalog.packages.empty()); + REQUIRE_EQ(catalog.issues.size(), size_t{1}); + RemoveSkin(root); +} + +TEST_CASE(candidate_skin_catalog_v2_without_canvas_loads_as_v1_and_reports_an_issue) +{ + // Even invalid v2 tables do not matter: they are not read at all. + const std::string window = std::string(kPanel) + R"toml(line_gap_dip = 6 + +[candidate_window.outer_insets_dip] +top = 20 + +[[candidate_window.decorations]] +image = "assets/mascot.apng" +width_dip = 40 +height_dip = 40 +z_index = "high" +)toml"; + for (const char *base : {"fluent", "wechat", "paper"}) + { + const auto root = WriteV2Skin(L"v2-fluent", base, window); + std::string error; + const auto package = CandidateSkinCatalog::Load(root, "art", &error); + REQUIRE(error.empty()); + REQUIRE(package.has_value()); + REQUIRE_EQ(package->schemaVersion, 1); + REQUIRE_EQ(package->notices.size(), size_t{1}); + REQUIRE(!package->borderImage.has_value()); + REQUIRE(!package->lineGapDip.has_value()); + REQUIRE(!package->outerInsetsDip.has_value()); + REQUIRE(package->freeDecorations.empty()); + + const auto catalog = CandidateSkinCatalog::Scan(root); + REQUIRE_EQ(catalog.packages.size(), size_t{1}); + REQUIRE_EQ(catalog.issues.size(), size_t{1}); + REQUIRE_EQ(catalog.issues.front().folder, std::string("art")); + REQUIRE_EQ(catalog.issues.front().reason, package->notices.front()); + RemoveSkin(root); + } +} + +TEST_CASE(candidate_skin_catalog_v2_accepts_animated_image_extensions_only) +{ + const auto root = WriteV2Skin(L"v2-ext", "canvas", R"toml( +[candidate_window.border_image] +source = "assets/mascot.apng" +slice_dip = [4, 8] +width_dip = [2, 4, 2, 4] +fill = false + +[[candidate_window.decorations]] +image = "assets/spark.GIF" +width_dip = 10 +height_dip = 10 + +[[candidate_window.decorations]] +image = "assets/leaf.webp" +anchor = "bottom-left" +offset_x_dip = -12.5 +offset_y_dip = 3 +width_dip = 10 +height_dip = 10 +z_index = -4 +)toml"); + std::string error; + const auto package = CandidateSkinCatalog::Load(root, "art", &error); + REQUIRE(error.empty()); + REQUIRE(package.has_value()); + REQUIRE(!package->borderImage->fill); + REQUIRE_EQ(package->borderImage->widthDip.size(), size_t{4}); + REQUIRE_EQ(package->freeDecorations.size(), size_t{2}); + REQUIRE_EQ(package->freeDecorations[1].offsetXDip, -12.5); + REQUIRE_EQ(package->freeDecorations[1].zIndex, -4); + RemoveSkin(root); + + REQUIRE(CandidateSkinCatalog::IsAnimatableImagePath("a/b.PNG")); + REQUIRE(CandidateSkinCatalog::IsAnimatableImagePath("b.apng")); + REQUIRE(!CandidateSkinCatalog::IsAnimatableImagePath("b.jpg")); + REQUIRE(!CandidateSkinCatalog::IsAnimatableImagePath("png")); + REQUIRE(!CandidateSkinCatalog::IsAnimatableImagePath("a.png/b")); +} + +TEST_CASE(candidate_skin_catalog_rejects_invalid_v2_values) +{ + const std::string panel = kPanel; + const std::string decoration = "\n[[candidate_window.decorations]]\nimage = \"assets/mascot.apng\"\n" + "width_dip = 40\nheight_dip = 40\n"; + REQUIRE( + V2LoadFails(L"bi-jpg", "[candidate_window.border_image]\nsource = \"assets/photo.jpg\"\nslice_dip = [4]\n")); + REQUIRE( + V2LoadFails(L"bi-missing", "[candidate_window.border_image]\nsource = \"assets/none.png\"\nslice_dip = [4]\n")); + REQUIRE(V2LoadFails(L"bi-no-slice", "[candidate_window.border_image]\nsource = \"assets/panel.png\"\n")); + REQUIRE(V2LoadFails(L"bi-slice-3", panel + "stretch_dip = [1, 2, 3]\n")); + REQUIRE(V2LoadFails(L"bi-slice-neg", "[candidate_window.border_image]\nsource = \"assets/panel.png\"\n" + "slice_dip = [-1]\n")); + REQUIRE(V2LoadFails(L"bi-slice-big", panel + "width_dip = [501]\n")); + REQUIRE(V2LoadFails(L"bi-width-scalar", panel + "width_dip = 4\n")); + REQUIRE(V2LoadFails(L"bi-fill", panel + "fill = \"no\"\n")); + REQUIRE(V2LoadFails(L"insets", "line_gap_dip = 4\n[candidate_window.content_insets_dip]\nleft = -2\n")); + REQUIRE(V2LoadFails(L"gap", "line_gap_dip = 101\n")); + REQUIRE(V2LoadFails(L"outer", "[candidate_window.outer_insets_dip]\ntop = 501\n")); + REQUIRE(V2LoadFails(L"dec-ext", "\n[[candidate_window.decorations]]\nimage = \"assets/photo.jpg\"\n" + "width_dip = 4\nheight_dip = 4\n")); + REQUIRE(V2LoadFails(L"dec-anchor", decoration + "anchor = \"middle\"\n")); + REQUIRE(V2LoadFails(L"dec-size", "\n[[candidate_window.decorations]]\nimage = \"assets/mascot.apng\"\n" + "width_dip = 0\nheight_dip = 40\n")); + REQUIRE(V2LoadFails(L"dec-offset", decoration + "offset_x_dip = 2001\n")); + REQUIRE(V2LoadFails(L"dec-offset-type", decoration + "offset_y_dip = \"4px\"\n")); + REQUIRE(V2LoadFails(L"dec-z-float", decoration + "z_index = 1.5\n")); + REQUIRE(V2LoadFails(L"dec-z-range", decoration + "z_index = 1001\n")); + std::string many; + for (size_t i = 0; i <= CandidateSkinCatalog::kMaxFreeDecorations; ++i) + { + many += decoration; + } + REQUIRE(V2LoadFails(L"dec-many", many)); +} \ No newline at end of file diff --git a/server/tests/src/test_candidate_skin_layers.cpp b/server/tests/src/test_candidate_skin_layers.cpp new file mode 100644 index 00000000..e8002d33 --- /dev/null +++ b/server/tests/src/test_candidate_skin_layers.cpp @@ -0,0 +1,98 @@ +#include "tests/includes/test_framework.h" + +#include "window/candidate_skin_layers.h" + +#include +#include + +namespace +{ +CandidateSkinCatalog::FreeDecoration Decoration(const std::string &image, int zIndex) +{ + CandidateSkinCatalog::FreeDecoration decoration; + decoration.image = image; + decoration.zIndex = zIndex; + return decoration; +} + +std::vector Images(const std::vector &layer) +{ + std::vector images; + for (const auto &decoration : layer) + images.push_back(decoration.image); + return images; +} + +bool SameEdges(const CandidateSkinLayers::Edges &edges, double top, double right, double bottom, double left) +{ + return edges.top == top && edges.right == right && edges.bottom == bottom && edges.left == left; +} +} // namespace + +TEST_CASE(candidate_skin_layers_expand_css_box_shorthand) +{ + REQUIRE(SameEdges(CandidateSkinLayers::ExpandBox({7}), 7, 7, 7, 7)); + REQUIRE(SameEdges(CandidateSkinLayers::ExpandBox({1, 2}), 1, 2, 1, 2)); + REQUIRE(SameEdges(CandidateSkinLayers::ExpandBox({1, 2, 3, 4}), 1, 2, 3, 4)); + REQUIRE(SameEdges(CandidateSkinLayers::ExpandBox({}), 0, 0, 0, 0)); +} + +TEST_CASE(candidate_skin_layers_nine_slice_follows_border_image) +{ + CandidateSkinCatalog::BorderImage image; + image.sliceDip = {16, 44, 16, 0}; + // Without width_dip the borders are as many DIPs wide as the slices are image pixels. + auto nine = CandidateSkinLayers::ResolveNineSlice(image); + REQUIRE(SameEdges(nine.slice, 16, 44, 16, 0)); + REQUIRE(SameEdges(nine.width, 16, 44, 16, 0)); + REQUIRE(nine.fill); + // stretch_dip replaces slice_dip; width_dip sets the drawn width (a 2x panel drawn at half its pixels). + image.stretchDip = {32, 88}; + image.widthDip = {16, 44}; + image.fill = false; + nine = CandidateSkinLayers::ResolveNineSlice(image); + REQUIRE(SameEdges(nine.slice, 32, 88, 32, 88)); + REQUIRE(SameEdges(nine.width, 16, 44, 16, 44)); + REQUIRE(!nine.fill); +} + +TEST_CASE(candidate_skin_layers_content_padding_is_measured_from_the_panel_edge) +{ + CandidateSkinCatalog::Package package; + package.schemaVersion = 2; + package.base = "canvas"; + // No panel, no insets: the base padding. + REQUIRE(SameEdges(CandidateSkinLayers::ResolveContentPadding(package, 5.0), 5, 5, 5, 5)); + + CandidateSkinCatalog::BorderImage image; + image.source = "assets/panel.png"; + image.sliceDip = {24}; + image.widthDip = {12, 20}; + package.borderImage = image; + // The panel's borders plus the base padding. + REQUIRE(SameEdges(CandidateSkinLayers::ResolveContentPadding(package, 5.0), 17, 25, 17, 25)); + + // content_insets_dip counts from the outer edge, but never puts text on the border. + package.contentInsetsDip = CandidateSkinCatalog::InsetsDip{4.0, 130.0, 14.0, 55.0}; + REQUIRE(SameEdges(CandidateSkinLayers::ResolveContentPadding(package, 5.0), 12, 130, 14, 55)); + + package.borderImage.reset(); + REQUIRE(SameEdges(CandidateSkinLayers::ResolveContentPadding(package, 5.0), 4, 130, 14, 55)); +} + +TEST_CASE(candidate_skin_layers_split_decorations_around_the_panel) +{ + const std::vector decorations = { + Decoration("a", 2), Decoration("b", 0), Decoration("c", -3), Decoration("d", 1), + Decoration("e", 0), Decoration("f", 5), Decoration("g", 2), + }; + const CandidateSkinLayers::Layers layers = CandidateSkinLayers::SplitDecorations(decorations); + // Below the panel: z_index < 1, lowest first, ties in declaration order. + REQUIRE_EQ(Images(layers.below), std::vector({"c", "b", "e"})); + // Above: z_index >= 1, the panel itself sitting at 1. + REQUIRE_EQ(Images(layers.above), std::vector({"d", "a", "g", "f"})); + REQUIRE_EQ(CandidateSkinCatalog::kDecorationFrontZIndex, 1); + + // The default z_index (2) draws above the panel. + REQUIRE(CandidateSkinLayers::SplitDecorations({CandidateSkinCatalog::FreeDecoration{}}).above.size() == 1); +} diff --git a/server/tests/src/test_candidate_skin_palette.cpp b/server/tests/src/test_candidate_skin_palette.cpp index 8ae5c07d..c2aa2493 100644 --- a/server/tests/src/test_candidate_skin_palette.cpp +++ b/server/tests/src/test_candidate_skin_palette.cpp @@ -119,3 +119,24 @@ TEST_CASE(candidate_skin_palette_flattens_translucent_surface_before_border_and_ REQUIRE_EQ(FlattenCandidateColor(flattened.border, flattened.surface), RGB(96, 0, 96)); REQUIRE_EQ(FlattenCandidateColor(flattened.text, flattened.surface), RGB(191, 128, 191)); } + +TEST_CASE(candidate_skin_palette_canvas_base_is_transparent_with_readable_text) +{ + for (const bool light : {false, true}) + { + const CandidateSkinPalette palette = ResolveCandidateSkinPalette("canvas", light, "auto"); + REQUIRE_EQ(palette.surface.a, 0.0f); + REQUIRE_EQ(palette.border.a, 0.0f); + REQUIRE_EQ(palette.text.a, 1.0f); + // GDI users flatten the transparent surface onto its own RGB: a readable fluent-like card. + REQUIRE_EQ(FlattenCandidateColor(palette.surface, palette.surface), + light ? RGB(255, 255, 255) : RGB(32, 32, 32)); + REQUIRE_EQ(FlattenCandidateColor(palette.text, palette.surface), light ? RGB(26, 26, 26) : RGB(242, 242, 242)); + REQUIRE_EQ(CandidateSkinBaseItemRadiusDip("canvas"), 4.0f); + } + // A package surface still wins over the canvas default. + CandidateSkinCatalog::CandidateColors colors; + colors.surface = "#10203080"; + const CandidateSkinPalette custom = ResolveCandidateSkinPalette("art", false, "auto", &colors, "canvas"); + REQUIRE(std::abs(custom.surface.a - 128.0f / 255.0f) < 0.001f); +} \ No newline at end of file diff --git a/skins/README.md b/skins/README.md index 4f10a5ea..0f57e63b 100644 --- a/skins/README.md +++ b/skins/README.md @@ -19,7 +19,7 @@ ## 默认皮肤设置(`default/`) -[`default/`](default/) 不是外部皮肤,而是六个内置皮肤(`fluent`、`wechat`、`graphite`、`willow_green`、`autumn_osmanthus`、`microsoft`)各自的设置清单。安装包会把它们放到数据目录的 `skins\default\\skin.toml`,升级时只补缺失的文件、不覆盖已有的,所以可以直接在那里改。 +[`default/`](default/) 不是外部皮肤,而是六个内置皮肤(`fluent`、`wechat`、`graphite`、`willow_green`、`autumn_osmanthus`、`microsoft`)各自的设置清单;`canvas` 基底没有自己的清单,开关沿用 `fluent` 的。安装包会把它们放到数据目录的 `skins\default\\skin.toml`,升级时只补缺失的文件、不覆盖已有的,所以可以直接在那里改。 内置皮肤的外观仍由输入法内置样式决定,这份清单只承载皮肤级的开关,目前支持: @@ -59,9 +59,9 @@ my-skin/ | 表 / 键 | 说明 | | --- | --- | -| `schema_version` | 皮肤格式版本,目前为 `1` | +| `schema_version` | 皮肤格式版本,`1` 或 `2`;`2` 是全贴图皮肤,必须以 `canvas` 为 base | | `id` / `name` / `version` / `author` / `description` | 基本信息,`id` 建议与目录名一致 | -| `base` | 继承的内置皮肤,如 `fluent`;未写的键沿用 base | +| `base` | 继承的内置皮肤,如 `fluent`;未写的键沿用 base。`canvas` 是只给 v2 皮肤用的全贴图基底(透明面板,外观来自九宫格面板图与挂件),不能单独选用,见 [schema 说明](../examples/skin-examples/schema/README.md) | | `[supports]` | `layouts`(`horizontal` / `vertical`)、`themes`(`dark` / `light`) | | `[candidate_window]` | `min_width_dip`、`corner_radius_dip`(0–32)、`border_width_dip`(0–4)、`item_corner_radius_dip`(高亮圆角,0–16)、`shadow`(`none` / `soft` / `strong`)、`font_family`(排在用户字体前面)、`page_arrows`(翻页箭头:横排在候选右侧,竖排在最后一行下方、与序号左对齐;不写时沿用 base 的[默认皮肤设置](#默认皮肤设置default),出厂关闭)、`fit_content`(卡片严格按文字宽度收紧,`min_width_dip` 与装饰图宽度仍然生效;不写时沿用 base 的默认皮肤设置,出厂关闭) | | `[candidate_window.decoration]` | 卡片上方的装饰图:`image`、`top_inset_dip`、`width_dip`、`align`(`left` / `center` / `right`) | diff --git a/ui/CMakeLists.txt b/ui/CMakeLists.txt index 899e1407..6914da8f 100644 --- a/ui/CMakeLists.txt +++ b/ui/CMakeLists.txt @@ -21,6 +21,7 @@ set(CMAKE_ARCHIVE_OUTPUT_DIRECTORY ${CMAKE_BINARY_DIR}/lib) add_library(msimeui STATIC src/Accordion.cpp + src/AnimatedImage.cpp src/Animation.cpp src/Application.cpp src/Brush.cpp @@ -53,6 +54,7 @@ add_library(msimeui STATIC src/tsf/TextLayout.cpp src/tsf/TextStore.cpp src/tsf/TsfTextServices.cpp + include/msimeui/AnimatedImage.h include/msimeui/Animation.h include/msimeui/Application.h include/msimeui/Brush.h diff --git a/ui/README.md b/ui/README.md index 35cb76f0..84d41386 100644 --- a/ui/README.md +++ b/ui/README.md @@ -28,7 +28,7 @@ ## 图片渲染 -`Image` 控件可以从文件加载 WIC 支持的图片格式,例如 PNG、JPEG、BMP、ICO、TIFF,以及 GIF 的静态首帧: +`Image` 控件可以从文件加载 WIC 支持的图片格式,例如 PNG、JPEG、BMP、ICO、TIFF,以及 APNG、GIF 动图: ```cpp auto image = std::make_shared(L"assets/sticker.png"); @@ -37,7 +37,19 @@ image->SetHeight(120.0f); image->SetStretch(msimeui::ImageStretch::Uniform); ``` -可用的缩放方式包括 `None`、`Fill`、`Uniform` 和 `UniformToFill`。位图按文件路径缓存在 `DeviceResources` 中,D2D 设备目标重建后会自动重新解码。当前 GIF 只显示第一帧,动画播放将在后续单独加入。 +可用的缩放方式包括 `None`、`Fill`、`Uniform` 和 `UniformToFill`。位图按文件路径缓存在 `DeviceResources` 中,D2D 设备目标重建后会自动重新解码。 + +### 动图 + +`AnimatedImage` 按 [APNG 规范](https://wiki.mozilla.org/APNG_Specification) 解析 `acTL` / `fcTL` / `fdAT`(校验序号、CRC 与帧边界),把每帧重建成独立 PNG 交给 WIC 解码,再按 `dispose_op` / `blend_op` 合成到整张画布上;GIF 走 WIC 多帧解码,按 `/grctlext` 的延时与处置方式、`/imgdesc` 的子矩形和 NETSCAPE 循环次数合成;WIC 编解码器暴露多帧的 WebP 按每帧 100 ms 播放。延时规则同浏览器:分母为 0 按 100,≤ 10 ms 按 100 ms;播完 `num_plays` 后停在最后一帧。损坏、超过 1024 帧或解码后超过 64 MB 的动画退回静态首帧,不引入第三方依赖。 + +`Image`、`Card` 的背景与九宫格都通过 `DeviceResources::GetImageFrame` 取当前帧: + +- 帧时间在 `BeginFrame` 采样,`SetAnimationEpoch` 设起点;`GetNextAnimationDeadline()`(或 `Scene::GetNextFrameDeadline()`)给出下一次换帧的时刻,按需重绘的宿主据此排一次定时器。`msimeui::Window` 自己会这样做。 +- `SetAnimationsEnabled(false)` 按静态解码显示(APNG 的默认图、GIF 首帧,与 `GetBitmapFromFile` 相同)、不再给出截止时间;是否减少动画由宿主按系统设置决定。`ClearImageCaches()` 丢掉已解码的图片,磁盘上换过的文件下次绘制时重新读取。 +- 动图的每帧都是 GPU 位图,合计受 `SetAnimatedBitmapBudget`(默认 64 MB)约束,按最近绘制淘汰;单个动画超出预算时只显示首帧。设备丢失后按需重建。 + +`DrawNineSlice` / `Card::SetBackgroundNineSlice` 实现 CSS `border-image`(`stretch`):切片以源图像素计,边框宽度以 DIP 计,`fill` 决定是否画中心;各块用夹取到自身的图像画刷绘制并对齐设备像素,接缝不会互相渗色。 ## 最近新增 diff --git a/ui/include/msimeui/AnimatedImage.h b/ui/include/msimeui/AnimatedImage.h new file mode 100644 index 00000000..fc753c09 --- /dev/null +++ b/ui/include/msimeui/AnimatedImage.h @@ -0,0 +1,126 @@ +#pragma once + +#include + +#include +#include +#include +#include +#include + +namespace msimeui +{ +// Frame timing of an animation, kept apart from the pixels so a GPU cache can keep it once the +// decoded frames are uploaded. +struct AnimationTimeline +{ + // How long each frame stays on screen, in milliseconds, already normalized by + // NormalizeFrameDelayMs. + std::vector frameDelaysMs; + // How many times the whole animation plays; 0 repeats forever. After the last play the last + // frame stays on screen. + uint32_t playCount = 0; + + struct Sample + { + size_t frameIndex = 0; + // Elapsed time (ms since the animation started) at which the frame on screen changes + // next. Empty when nothing changes any more: a single frame, or a finished animation that + // now holds its last frame. + std::optional nextChangeMs; + }; + + uint64_t LoopDurationMs() const; + Sample SampleAt(uint64_t elapsedMs) const; +}; + +// Browsers show frames that ask for 10 ms or less for 100 ms instead; so does this library, so +// an animation runs at the speed its author previewed it at. +uint32_t NormalizeFrameDelayMs(uint32_t delayMs); + +// Decoding limits; an animation beyond them shows its still first frame. At namespace scope so +// it is complete where AnimatedImage uses it as a default argument. +struct AnimatedImageLimits +{ + uint32_t maxFrames = 1024; + // All frames together, decoded (width * height * 4 * frames). + uint64_t maxDecodedBytes = 64ull * 1024 * 1024; + uint64_t maxFileBytes = 64ull * 1024 * 1024; + uint32_t maxDimension = 16384; +}; + +// A decoded still or animated image: every frame composited onto a full-size, premultiplied +// BGRA canvas, ready to upload as it is. +// +// Animated PNG is decoded per the APNG specification (acTL / fcTL / fdAT): each frame is rebuilt +// as a standalone PNG and decoded with WIC, then composited honoring dispose_op and blend_op. +// Animated GIF goes through WIC's multi-frame GIF decoder with the same compositing rules. Any +// other container that WIC reports as multi-frame (animated WebP when the installed codec exposes +// its frames) is played frame by frame at 100 ms. Malformed or oversized animations fall back to +// the still first frame; they never fail harder than the still image would. +class AnimatedImage +{ + public: + enum class Format + { + Still, + Apng, + Gif, + MultiFrame, + }; + + using Limits = AnimatedImageLimits; + + // Returns nothing when not even the still first frame can be decoded. + static std::optional DecodeFile(IWICImagingFactory *factory, const std::wstring &filePath, + const Limits &limits = Limits()); + static std::optional DecodeMemory(IWICImagingFactory *factory, const uint8_t *data, size_t size, + const Limits &limits = Limits()); + // Cheap check without decoding pixels: true when the file looks like an animation (an APNG + // acTL chunk, or a WIC container with more than one frame). Still images can skip DecodeFile. + static bool MayBeAnimated(IWICImagingFactory *factory, const std::wstring &filePath); + + uint32_t Width() const + { + return width_; + } + uint32_t Height() const + { + return height_; + } + size_t FrameCount() const + { + return frames_.size(); + } + bool IsAnimated() const + { + return frames_.size() > 1; + } + Format GetFormat() const + { + return format_; + } + const AnimationTimeline &Timeline() const + { + return timeline_; + } + // Premultiplied BGRA, Width() * 4 bytes per row, the whole canvas. + const std::vector &FramePixels(size_t index) const + { + return frames_[index]; + } + uint64_t DecodedBytes() const + { + return static_cast(width_) * height_ * 4 * frames_.size(); + } + + private: + friend struct AnimatedImageDecoder; + + uint32_t width_ = 0; + uint32_t height_ = 0; + Format format_ = Format::Still; + AnimationTimeline timeline_; + std::vector> frames_; +}; +} // namespace msimeui diff --git a/ui/include/msimeui/DeviceResources.h b/ui/include/msimeui/DeviceResources.h index 4d86cb8f..c73efa85 100644 --- a/ui/include/msimeui/DeviceResources.h +++ b/ui/include/msimeui/DeviceResources.h @@ -7,12 +7,15 @@ #include #include #include +#include #include +#include #include #include #include #include +#include "AnimatedImage.h" #include "Types.h" namespace msimeui @@ -20,8 +23,12 @@ namespace msimeui class DeviceResources { public: + using AnimationClock = std::chrono::steady_clock; + // Marks the start of a frame. Brushes not used since the previous frame become - // evictable, and the clip stack used for culling starts over. + // evictable, and the clip stack used for culling starts over. The animation clock is + // sampled here, so every animated image in the frame shows the same moment, and the + // next-frame deadline starts over. void BeginFrame(); // Axis-aligned clip that also feeds IsVisible(), so containers can skip children @@ -63,6 +70,30 @@ class DeviceResources DWRITE_TEXT_ALIGNMENT textAlignment, DWRITE_PARAGRAPH_ALIGNMENT paragraphAlignment, DWRITE_WORD_WRAPPING wordWrapping); ID2D1Bitmap *GetBitmapFromFile(const std::wstring &filePath, D2D1_SIZE_F *size = nullptr); + // Like GetBitmapFromFile, but an animated image (APNG, animated GIF, multi-frame WebP) + // returns the frame for the current animation time and moves GetNextAnimationDeadline() + // to the moment that frame changes. Still images take the GetBitmapFromFile path. Sizes + // are in DIPs at 96 DPI, one DIP per image pixel, the same as GetBitmapFromFile. + ID2D1Bitmap *GetImageFrame(const std::wstring &filePath, D2D1_SIZE_F *size = nullptr); + // Drops every decoded image, still and animated, so files changed on disk are read again. + void ClearImageCaches(); + // Off: images are drawn still, as GetBitmapFromFile decodes them (the APNG default image, + // the first GIF frame), and ask for no further frames. Hosts turn this off when the system + // asks for reduced motion. + void SetAnimationsEnabled(bool enabled); + bool AreAnimationsEnabled() const; + // Animations measure their elapsed time from this point; moving it restarts them all. + void SetAnimationEpoch(AnimationClock::time_point epoch); + // Overrides the time BeginFrame sampled, for hosts that keep their own frame clock. + void SetFrameTime(AnimationClock::time_point now); + // Earliest moment an animated image drawn since BeginFrame changes; empty when nothing + // drawn is animating. Hosts schedule their next frame for it. + std::optional GetNextAnimationDeadline() const; + // GPU memory all animated images may take together (every frame is a bitmap). An + // animation that does not fit on its own is shown still; others evict the least + // recently drawn animation. + void SetAnimatedBitmapBudget(size_t bytes); + size_t GetAnimatedBitmapBytes() const; // Rasterizes an SVG document (UTF-8 markup) at `sizeDips` for the current DPI and // caches the bitmap. Needs ID2D1DeviceContext5 (Windows 10 1703+); null otherwise. ID2D1Bitmap *GetSvgBitmap(const std::string &markup, const SizeF &sizeDips); @@ -102,7 +133,23 @@ class DeviceResources Microsoft::WRL::ComPtr bitmap; }; + struct AnimatedBitmapEntry + { + std::wstring filePath; + D2D1_SIZE_F size = {}; + std::vector> frames; + AnimationTimeline timeline; + size_t bytes = 0; + uint64_t lastFrame = 0; + }; + + AnimatedBitmapEntry *FindOrLoadAnimation(const std::wstring &filePath); + void RememberStillImage(const std::wstring &filePath); + void TrimAnimatedBitmaps(size_t incoming); + static constexpr size_t kMaxCachedBitmaps = 4; + static constexpr size_t kDefaultAnimatedBitmapBudget = 64ULL * 1024 * 1024; + static constexpr size_t kMaxRememberedStillImages = 64; static constexpr size_t kMaxCachedSvgBitmaps = 96; // Animated colors produce a new brush every frame; past this many, brushes left // over from earlier frames are dropped. @@ -141,5 +188,16 @@ class DeviceResources std::vector bitmapCache_; std::vector svgCache_; std::vector clipStack_; + // Animated images, decoded once per device. Paths found to be still images are + // remembered so they are not probed again; that is a property of the file, not of + // the device, so it survives DiscardTarget. + std::vector animatedCache_; + std::vector stillImagePaths_; + size_t animatedBytes_ = 0; + size_t animatedBudget_ = kDefaultAnimatedBitmapBudget; + bool animationsEnabled_ = true; + AnimationClock::time_point animationEpoch_ = AnimationClock::now(); + std::optional frameTime_; + std::optional nextAnimationDeadline_; }; } // namespace msimeui diff --git a/ui/include/msimeui/Layout.h b/ui/include/msimeui/Layout.h index 2e9ac4ff..76a4aef4 100644 --- a/ui/include/msimeui/Layout.h +++ b/ui/include/msimeui/Layout.h @@ -424,6 +424,24 @@ class ShadowCache std::vector passes_; }; +// CSS `border-image` with `border-image-repeat: stretch`. The source image is cut along `slice` +// (image pixels, like the numbers of `border-image-slice`) and drawn into borders `width` wide +// (DIPs, like `border-image-width`): corners scale from slice to width, edges stretch along +// their length, the middle stretches both ways and is drawn only with `fill`. +struct NineSlice +{ + Thickness slice; + Thickness width; + bool fill = true; +}; + +// Draws `bitmap` nine-sliced into `destination` (DIPs). Bitmap sizes are read in DIPs at 96 DPI, +// one DIP per image pixel, as DeviceResources creates them. Slices larger than the image are +// clamped to it, and when two opposite widths do not fit into the box they are all scaled down +// by the same factor, as CSS does. Part edges land on device pixels so the parts meet seamlessly. +void DrawNineSlice(ID2D1RenderTarget *target, ID2D1Bitmap *bitmap, const RectF &destination, const NineSlice &nineSlice, + float opacity = 1.0f); + class Card : public Panel { public: @@ -442,13 +460,13 @@ class Card : public Panel // Turns the drop shadow off entirely; enabled by default. void SetShadowEnabled(bool enabled); // Draws an image file over the fill and under the children, clipped to the rounded - // card shape. An empty path removes it. + // card shape. An empty path removes it. Animated images play (DeviceResources::GetImageFrame). void SetBackgroundImage(std::wstring filePath, ImageStretch stretch = ImageStretch::UniformToFill, float opacity = 1.0f); - // 九宫格背景:四边按切片原样绘制,其余单向/双向拉伸,用来铺「面板即贴图」的 v2 皮肤。 - // 切片单位与源图像素一致;给出后覆盖 SetBackgroundImage 的普通背景。 - void SetBackgroundImageNineSlice(std::wstring filePath, float left, float top, float right, float bottom, - float opacity = 1.0f); + // Nine-sliced panel image (see NineSlice) over the fill and under the stroke and children. + // Like CSS border-image it is not clipped to the corner radius. Replaces SetBackgroundImage. + // Animated images play, though a panel is usually best left still. + void SetBackgroundNineSlice(std::wstring filePath, const NineSlice &nineSlice, float opacity = 1.0f); SizeF Measure(const SizeF &availableSize) override; void Arrange(const RectF &finalRect) override; @@ -465,10 +483,7 @@ class Card : public Panel ImageStretch backgroundStretch_ = ImageStretch::UniformToFill; float backgroundOpacity_ = 1.0f; bool backgroundNineSlice_ = false; - float sliceLeft_ = 0.0f; - float sliceTop_ = 0.0f; - float sliceRight_ = 0.0f; - float sliceBottom_ = 0.0f; + NineSlice nineSlice_; float shadowScale_ = 1.0f; float shadowOpacity_ = 1.0f; std::vector shadowPasses_; diff --git a/ui/include/msimeui/Scene.h b/ui/include/msimeui/Scene.h index b0f2781b..ba821e08 100644 --- a/ui/include/msimeui/Scene.h +++ b/ui/include/msimeui/Scene.h @@ -2,8 +2,10 @@ #include "Layout.h" +#include #include #include +#include #include namespace msimeui @@ -31,7 +33,13 @@ class Scene void InvalidateArrange(); void InvalidateArrange(Visual *source); void InvalidateVisual(); + // Hosts call DeviceResources::BeginFrame before this, so the animation clock and the + // next-frame deadline belong to this frame. void Render(DeviceResources &deviceResources); + // When the scene rendered last needs its next frame: the earliest change of an animated + // image it drew. Empty when nothing on screen animates. Hosts that render on demand + // schedule one repaint for it (msimeui::Window does so itself). + std::optional GetNextFrameDeadline() const; Visual *FindVisualAt(const PointF &point); Visual *FindFocusableAt(const PointF &point); bool OnMouseWheel(const POINT &point, short delta, WPARAM keyState); @@ -48,5 +56,6 @@ class Scene bool hasLastLayoutSize_ = false; bool measureDirty_ = true; bool arrangeDirty_ = true; + std::optional nextFrameDeadline_; }; } // namespace msimeui diff --git a/ui/include/msimeui/Window.h b/ui/include/msimeui/Window.h index 05cce12f..c93e068f 100644 --- a/ui/include/msimeui/Window.h +++ b/ui/include/msimeui/Window.h @@ -80,6 +80,9 @@ class Window void SetScene(std::unique_ptr scene); private: + // Repaints when the next frame of an animated image is due. + static constexpr UINT_PTR kImageAnimationTimerId = 0x4D49; + static LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM wParam, LPARAM lParam); LRESULT HandleMessage(UINT message, WPARAM wParam, LPARAM lParam); void OnPaint(); diff --git a/ui/src/AnimatedImage.cpp b/ui/src/AnimatedImage.cpp new file mode 100644 index 00000000..005cec49 --- /dev/null +++ b/ui/src/AnimatedImage.cpp @@ -0,0 +1,914 @@ +// AnimatedImage: decodes still images, APNG and animated GIF into composited BGRA frames. +#include "msimeui/AnimatedImage.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace msimeui +{ +using Microsoft::WRL::ComPtr; + +namespace +{ +constexpr uint8_t kPngSignature[8] = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}; + +constexpr uint32_t Tag(char a, char b, char c, char d) +{ + return (static_cast(static_cast(a)) << 24) | + (static_cast(static_cast(b)) << 16) | + (static_cast(static_cast(c)) << 8) | static_cast(static_cast(d)); +} + +constexpr uint32_t kIhdr = Tag('I', 'H', 'D', 'R'); +constexpr uint32_t kIdat = Tag('I', 'D', 'A', 'T'); +constexpr uint32_t kIend = Tag('I', 'E', 'N', 'D'); +constexpr uint32_t kActl = Tag('a', 'c', 'T', 'L'); +constexpr uint32_t kFctl = Tag('f', 'c', 'T', 'L'); +constexpr uint32_t kFdat = Tag('f', 'd', 'A', 'T'); + +// APNG dispose_op / blend_op, also used for GIF disposal. +enum class Dispose : uint8_t +{ + None = 0, + Background = 1, + Previous = 2, +}; + +uint32_t ReadBE32(const uint8_t *p) +{ + return (static_cast(p[0]) << 24) | (static_cast(p[1]) << 16) | + (static_cast(p[2]) << 8) | static_cast(p[3]); +} + +uint16_t ReadBE16(const uint8_t *p) +{ + return static_cast((p[0] << 8) | p[1]); +} + +void AppendBE32(std::vector &out, uint32_t value) +{ + out.push_back(static_cast(value >> 24)); + out.push_back(static_cast(value >> 16)); + out.push_back(static_cast(value >> 8)); + out.push_back(static_cast(value)); +} + +const std::array &CrcTable() +{ + static const std::array table = []() { + std::array result{}; + for (uint32_t n = 0; n < 256; ++n) + { + uint32_t c = n; + for (int k = 0; k < 8; ++k) + { + c = (c & 1U) ? 0xEDB88320U ^ (c >> 1) : c >> 1; + } + result[n] = c; + } + return result; + }(); + return table; +} + +uint32_t UpdateCrc(uint32_t crc, const uint8_t *data, size_t size) +{ + const auto &table = CrcTable(); + for (size_t i = 0; i < size; ++i) + { + crc = table[(crc ^ data[i]) & 0xFFU] ^ (crc >> 8); + } + return crc; +} + +// CRC of a chunk covers its type and data. +uint32_t ChunkCrc(uint32_t type, const uint8_t *data, size_t size) +{ + const uint8_t typeBytes[4] = {static_cast(type >> 24), static_cast(type >> 16), + static_cast(type >> 8), static_cast(type)}; + uint32_t crc = UpdateCrc(0xFFFFFFFFU, typeBytes, 4); + crc = UpdateCrc(crc, data, size); + return crc ^ 0xFFFFFFFFU; +} + +void AppendChunk(std::vector &out, uint32_t type, const uint8_t *data, size_t size) +{ + AppendBE32(out, static_cast(size)); + AppendBE32(out, type); + if (size > 0) + { + out.insert(out.end(), data, data + size); + } + AppendBE32(out, ChunkCrc(type, data, size)); +} + +struct PngChunk +{ + uint32_t type = 0; + const uint8_t *data = nullptr; + uint32_t length = 0; +}; + +bool HasPngSignature(const uint8_t *data, size_t size) +{ + return size >= sizeof(kPngSignature) && std::memcmp(data, kPngSignature, sizeof(kPngSignature)) == 0; +} + +// Splits a PNG stream into chunks up to IEND. With `verifyCrc`, any chunk whose CRC does not match +// fails the whole stream; a truncated stream always fails. +bool ReadPngChunks(const uint8_t *data, size_t size, bool verifyCrc, std::vector &chunks) +{ + if (!HasPngSignature(data, size)) + { + return false; + } + size_t pos = sizeof(kPngSignature); + while (pos + 12 <= size) + { + const uint32_t length = ReadBE32(data + pos); + if (length > 0x7FFFFFFFU || length > size - pos - 12) + { + return false; + } + PngChunk chunk; + chunk.type = ReadBE32(data + pos + 4); + chunk.data = data + pos + 8; + chunk.length = length; + if (verifyCrc && ChunkCrc(chunk.type, chunk.data, length) != ReadBE32(data + pos + 8 + length)) + { + return false; + } + chunks.push_back(chunk); + pos += 12 + static_cast(length); + if (chunk.type == kIend) + { + return true; + } + } + return false; +} + +ComPtr CreateDecoder(IWICImagingFactory *factory, const uint8_t *data, size_t size) +{ + ComPtr decoder; + if (!factory || !data || size == 0 || size > 0xFFFFFFFFULL) + { + return decoder; + } + ComPtr stream; + stream.Attach(SHCreateMemStream(data, static_cast(size))); + if (!stream || FAILED(factory->CreateDecoderFromStream(stream.Get(), nullptr, WICDecodeMetadataCacheOnLoad, + decoder.GetAddressOf()))) + { + decoder.Reset(); + } + return decoder; +} + +// Converts any WIC source to premultiplied BGRA. +bool CopyPremultipliedBgra(IWICImagingFactory *factory, IWICBitmapSource *source, uint32_t maxDimension, + uint32_t &width, uint32_t &height, std::vector &pixels) +{ + UINT w = 0; + UINT h = 0; + ComPtr converter; + if (!source || FAILED(source->GetSize(&w, &h)) || w == 0 || h == 0 || w > maxDimension || h > maxDimension || + FAILED(factory->CreateFormatConverter(converter.GetAddressOf())) || + FAILED(converter->Initialize(source, GUID_WICPixelFormat32bppPBGRA, WICBitmapDitherTypeNone, nullptr, 0.0, + WICBitmapPaletteTypeMedianCut))) + { + return false; + } + const size_t stride = static_cast(w) * 4; + pixels.assign(stride * h, 0); + if (FAILED( + converter->CopyPixels(nullptr, static_cast(stride), static_cast(pixels.size()), pixels.data()))) + { + return false; + } + width = w; + height = h; + return true; +} + +// A frame rectangle on the canvas, already known to lie inside it. +struct Region +{ + uint32_t x = 0; + uint32_t y = 0; + uint32_t width = 0; + uint32_t height = 0; +}; + +class Canvas +{ + public: + Canvas(uint32_t width, uint32_t height) + : width_(width), height_(height), pixels_(static_cast(width) * height * 4, 0) + { + } + + // Draws `frame` (frameWidth x frameHeight) with its top-left at (left, top). Parts outside the + // canvas are dropped. `over` blends premultiplied source over destination; otherwise the source + // replaces the destination, transparent pixels included. + void Draw(const std::vector &frame, uint32_t frameWidth, uint32_t frameHeight, int64_t left, int64_t top, + bool over) + { + const Region region = Clip(left, top, frameWidth, frameHeight); + for (uint32_t row = 0; row < region.height; ++row) + { + const size_t srcY = static_cast(region.y - top + row); + const size_t srcX = static_cast(region.x - left); + const uint8_t *src = frame.data() + (srcY * frameWidth + srcX) * 4; + uint8_t *dst = pixels_.data() + ((static_cast(region.y) + row) * width_ + region.x) * 4; + if (!over) + { + std::memcpy(dst, src, static_cast(region.width) * 4); + continue; + } + for (uint32_t col = 0; col < region.width; ++col, src += 4, dst += 4) + { + const uint32_t alpha = src[3]; + if (alpha == 255) + { + std::memcpy(dst, src, 4); + } + else if (alpha != 0) + { + for (int c = 0; c < 4; ++c) + { + dst[c] = + static_cast((std::min)(255U, src[c] + (dst[c] * (255U - alpha) + 127U) / 255U)); + } + } + } + } + } + + Region Clip(int64_t left, int64_t top, uint32_t width, uint32_t height) const + { + const int64_t x0 = (std::max)(left, int64_t{0}); + const int64_t y0 = (std::max)(top, int64_t{0}); + const int64_t x1 = (std::min)(left + static_cast(width), static_cast(width_)); + const int64_t y1 = (std::min)(top + static_cast(height), static_cast(height_)); + if (x1 <= x0 || y1 <= y0) + { + return {}; + } + return {static_cast(x0), static_cast(y0), static_cast(x1 - x0), + static_cast(y1 - y0)}; + } + + void Clear(const Region ®ion) + { + for (uint32_t row = 0; row < region.height; ++row) + { + std::memset(pixels_.data() + ((static_cast(region.y) + row) * width_ + region.x) * 4, 0, + static_cast(region.width) * 4); + } + } + + std::vector Save(const Region ®ion) const + { + std::vector saved(static_cast(region.width) * region.height * 4); + for (uint32_t row = 0; row < region.height; ++row) + { + std::memcpy(saved.data() + static_cast(row) * region.width * 4, + pixels_.data() + ((static_cast(region.y) + row) * width_ + region.x) * 4, + static_cast(region.width) * 4); + } + return saved; + } + + void Restore(const Region ®ion, const std::vector &saved) + { + for (uint32_t row = 0; row < region.height; ++row) + { + std::memcpy(pixels_.data() + ((static_cast(region.y) + row) * width_ + region.x) * 4, + saved.data() + static_cast(row) * region.width * 4, + static_cast(region.width) * 4); + } + } + + const std::vector &Pixels() const + { + return pixels_; + } + + private: + uint32_t width_; + uint32_t height_; + std::vector pixels_; +}; + +// One composited frame of the source animation: where it lands, how it blends, what happens after. +struct FrameStep +{ + std::vector pixels; + uint32_t width = 0; + uint32_t height = 0; + int64_t left = 0; + int64_t top = 0; + bool over = false; + Dispose dispose = Dispose::None; +}; + +// Applies one frame to the canvas and returns what is on screen while it shows. Dispose runs +// afterwards, so it shapes the canvas the next frame starts from. +std::vector ComposeStep(Canvas &canvas, const FrameStep &step, bool first) +{ + Dispose dispose = step.dispose; + // Nothing precedes the first frame, so "restore previous" means "clear" there. + if (first && dispose == Dispose::Previous) + { + dispose = Dispose::Background; + } + const Region region = canvas.Clip(step.left, step.top, step.width, step.height); + std::vector saved; + if (dispose == Dispose::Previous) + { + saved = canvas.Save(region); + } + canvas.Draw(step.pixels, step.width, step.height, step.left, step.top, step.over); + std::vector shown = canvas.Pixels(); + if (dispose == Dispose::Background) + { + canvas.Clear(region); + } + else if (dispose == Dispose::Previous) + { + canvas.Restore(region, saved); + } + return shown; +} + +bool ReadMetadataUInt(IWICMetadataQueryReader *reader, const wchar_t *name, uint32_t &value) +{ + if (!reader) + { + return false; + } + PROPVARIANT variant; + PropVariantInit(&variant); + bool ok = false; + if (SUCCEEDED(reader->GetMetadataByName(name, &variant))) + { + switch (variant.vt) + { + case VT_UI1: + value = variant.bVal; + ok = true; + break; + case VT_UI2: + value = variant.uiVal; + ok = true; + break; + case VT_UI4: + value = variant.ulVal; + ok = true; + break; + default: + break; + } + } + PropVariantClear(&variant); + return ok; +} + +bool ReadMetadataBytes(IWICMetadataQueryReader *reader, const wchar_t *name, std::vector &bytes) +{ + if (!reader) + { + return false; + } + PROPVARIANT variant; + PropVariantInit(&variant); + bool ok = false; + if (SUCCEEDED(reader->GetMetadataByName(name, &variant)) && variant.vt == (VT_UI1 | VT_VECTOR)) + { + bytes.assign(variant.caub.pElems, variant.caub.pElems + variant.caub.cElems); + ok = true; + } + PropVariantClear(&variant); + return ok; +} + +bool ReadFileBytes(const std::wstring &filePath, uint64_t maxBytes, std::vector &bytes) +{ + HANDLE file = CreateFileW(filePath.c_str(), GENERIC_READ, FILE_SHARE_READ | FILE_SHARE_DELETE, nullptr, + OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr); + if (file == INVALID_HANDLE_VALUE) + { + return false; + } + LARGE_INTEGER size{}; + bool ok = GetFileSizeEx(file, &size) && size.QuadPart > 0 && static_cast(size.QuadPart) <= maxBytes; + if (ok) + { + bytes.resize(static_cast(size.QuadPart)); + size_t done = 0; + while (ok && done < bytes.size()) + { + DWORD read = 0; + const DWORD chunk = static_cast((std::min)(bytes.size() - done, size_t{1} << 30)); + ok = ReadFile(file, bytes.data() + done, chunk, &read, nullptr) && read > 0; + done += read; + } + } + CloseHandle(file); + return ok; +} + +bool FitsLimits(uint32_t width, uint32_t height, size_t frames, const AnimatedImage::Limits &limits) +{ + if (width == 0 || height == 0 || width > limits.maxDimension || height > limits.maxDimension || + frames > limits.maxFrames) + { + return false; + } + return static_cast(width) * height * 4 * frames <= limits.maxDecodedBytes; +} +} // namespace + +struct AnimatedImageDecoder +{ + // APNG per https://wiki.mozilla.org/APNG_Specification. Anything that breaks the rules there + // (sequence numbers, CRCs, frame bounds, frame count, frames without data) returns false and the + // caller shows the still default image instead. + static bool DecodeApng(IWICImagingFactory *factory, const uint8_t *data, size_t size, + const AnimatedImage::Limits &limits, AnimatedImage &out) + { + std::vector chunks; + if (!ReadPngChunks(data, size, true, chunks) || chunks.empty() || chunks.front().type != kIhdr || + chunks.front().length != 13) + { + return false; + } + const uint8_t *ihdr = chunks.front().data; + const uint32_t width = ReadBE32(ihdr); + const uint32_t height = ReadBE32(ihdr + 4); + + struct Frame + { + uint32_t width = 0; + uint32_t height = 0; + uint32_t x = 0; + uint32_t y = 0; + uint16_t delayNum = 0; + uint16_t delayDen = 0; + uint8_t dispose = 0; + uint8_t blend = 0; + std::vector> data; + }; + std::vector frames; + std::vector header; // chunks between IHDR and the first IDAT, copied into every frame + bool haveActl = false; + uint32_t declaredFrames = 0; + uint32_t playCount = 0; + uint32_t sequence = 0; + bool idatSeen = false; + bool idatDone = false; + // The default image (IDAT) is the first frame only when an fcTL precedes it. + bool defaultIsFrame = false; + uint32_t previousType = kIhdr; + for (size_t i = 1; i < chunks.size(); ++i) + { + const PngChunk &chunk = chunks[i]; + if (idatSeen && previousType == kIdat && chunk.type != kIdat) + { + idatDone = true; + } + previousType = chunk.type; + switch (chunk.type) + { + case kActl: + if (haveActl || idatSeen || chunk.length != 8) + { + return false; + } + haveActl = true; + declaredFrames = ReadBE32(chunk.data); + playCount = ReadBE32(chunk.data + 4); + break; + case kFctl: { + if (chunk.length != 26 || ReadBE32(chunk.data) != sequence++) + { + return false; + } + // A frame must have its data before the next frame begins. + if (!frames.empty() && frames.back().data.empty()) + { + return false; + } + Frame frame; + frame.width = ReadBE32(chunk.data + 4); + frame.height = ReadBE32(chunk.data + 8); + frame.x = ReadBE32(chunk.data + 12); + frame.y = ReadBE32(chunk.data + 16); + frame.delayNum = ReadBE16(chunk.data + 20); + frame.delayDen = ReadBE16(chunk.data + 22); + frame.dispose = chunk.data[24]; + frame.blend = chunk.data[25]; + if (frame.width == 0 || frame.height == 0 || frame.x > width || frame.y > height || + frame.width > width - frame.x || frame.height > height - frame.y || frame.dispose > 2 || + frame.blend > 1) + { + return false; + } + if (!idatSeen) + { + // The fcTL before IDAT describes the default image, which fills the canvas. + if (!frames.empty() || frame.x != 0 || frame.y != 0 || frame.width != width || + frame.height != height) + { + return false; + } + defaultIsFrame = true; + } + frames.push_back(std::move(frame)); + break; + } + case kIdat: + if (idatDone) + { + return false; + } + idatSeen = true; + if (defaultIsFrame) + { + if (frames.size() != 1) + { + return false; + } + frames.front().data.emplace_back(chunk.data, chunk.length); + } + break; + case kFdat: + // fdAT belongs to the latest fcTL after the default image; it never extends IDAT. + if (chunk.length < 4 || ReadBE32(chunk.data) != sequence++ || !idatSeen || frames.empty() || + (defaultIsFrame && frames.size() == 1)) + { + return false; + } + frames.back().data.emplace_back(chunk.data + 4, chunk.length - 4); + break; + case kIend: + break; + default: + if (!idatSeen) + { + header.push_back(chunk); + } + break; + } + } + if (!haveActl || !idatSeen || declaredFrames == 0 || frames.size() != declaredFrames || + !FitsLimits(width, height, frames.size(), limits)) + { + return false; + } + for (const Frame &frame : frames) + { + if (frame.data.empty()) + { + return false; + } + } + + Canvas canvas(width, height); + out.frames_.clear(); + out.timeline_ = {}; + for (size_t index = 0; index < frames.size(); ++index) + { + const Frame &frame = frames[index]; + // A standalone PNG of this frame: IHDR with the frame size, the shared header chunks + // (PLTE, tRNS, gAMA, iCCP ...), the frame data as IDAT, IEND. + std::vector png(kPngSignature, kPngSignature + sizeof(kPngSignature)); + uint8_t frameIhdr[13]; + std::memcpy(frameIhdr, ihdr, sizeof(frameIhdr)); + for (int b = 0; b < 4; ++b) + { + frameIhdr[b] = static_cast(frame.width >> (24 - 8 * b)); + frameIhdr[4 + b] = static_cast(frame.height >> (24 - 8 * b)); + } + AppendChunk(png, kIhdr, frameIhdr, sizeof(frameIhdr)); + for (const PngChunk &chunk : header) + { + if (chunk.type != kActl && chunk.type != kFctl) + { + AppendChunk(png, chunk.type, chunk.data, chunk.length); + } + } + for (const auto &part : frame.data) + { + AppendChunk(png, kIdat, part.first, part.second); + } + AppendChunk(png, kIend, nullptr, 0); + + ComPtr decoder = CreateDecoder(factory, png.data(), png.size()); + ComPtr decoded; + FrameStep step; + if (!decoder || FAILED(decoder->GetFrame(0, decoded.GetAddressOf())) || + !CopyPremultipliedBgra(factory, decoded.Get(), limits.maxDimension, step.width, step.height, + step.pixels) || + step.width != frame.width || step.height != frame.height) + { + return false; + } + step.left = frame.x; + step.top = frame.y; + step.over = frame.blend == 1; + step.dispose = static_cast(frame.dispose); + out.frames_.push_back(ComposeStep(canvas, step, index == 0)); + + const uint32_t den = frame.delayDen == 0 ? 100U : frame.delayDen; + const uint32_t delayMs = (static_cast(frame.delayNum) * 1000U + den / 2U) / den; + out.timeline_.frameDelaysMs.push_back(NormalizeFrameDelayMs(delayMs)); + } + out.width_ = width; + out.height_ = height; + out.timeline_.playCount = playCount; + out.format_ = AnimatedImage::Format::Apng; + return true; + } + + static bool DecodeGif(IWICImagingFactory *factory, IWICBitmapDecoder *decoder, const AnimatedImage::Limits &limits, + AnimatedImage &out) + { + UINT count = 0; + if (FAILED(decoder->GetFrameCount(&count)) || count < 2) + { + return false; + } + ComPtr reader; + decoder->GetMetadataQueryReader(reader.GetAddressOf()); + uint32_t width = 0; + uint32_t height = 0; + ReadMetadataUInt(reader.Get(), L"/logscrdesc/Width", width); + ReadMetadataUInt(reader.Get(), L"/logscrdesc/Height", height); + if (width == 0 || height == 0) + { + ComPtr first; + UINT w = 0; + UINT h = 0; + if (FAILED(decoder->GetFrame(0, first.GetAddressOf())) || FAILED(first->GetSize(&w, &h))) + { + return false; + } + width = w; + height = h; + } + if (!FitsLimits(width, height, count, limits)) + { + return false; + } + // Without a NETSCAPE2.0 block a GIF plays once. Its loop count says how often to repeat + // after the first play (0 forever), which is how browsers read it. + uint32_t playCount = 1; + std::vector application; + std::vector loop; + if (ReadMetadataBytes(reader.Get(), L"/appext/application", application) && application.size() == 11 && + (std::memcmp(application.data(), "NETSCAPE2.0", 11) == 0 || + std::memcmp(application.data(), "ANIMEXTS1.0", 11) == 0) && + ReadMetadataBytes(reader.Get(), L"/appext/data", loop) && loop.size() >= 4 && loop[0] >= 3 && loop[1] == 1) + { + const uint32_t repeats = static_cast(loop[2]) | (static_cast(loop[3]) << 8); + playCount = repeats == 0 ? 0 : repeats + 1; + } + + Canvas canvas(width, height); + out.frames_.clear(); + out.timeline_ = {}; + for (UINT index = 0; index < count; ++index) + { + ComPtr frame; + if (FAILED(decoder->GetFrame(index, frame.GetAddressOf()))) + { + return false; + } + ComPtr frameReader; + frame->GetMetadataQueryReader(frameReader.GetAddressOf()); + uint32_t left = 0; + uint32_t top = 0; + uint32_t delay = 0; + uint32_t disposal = 0; + ReadMetadataUInt(frameReader.Get(), L"/imgdesc/Left", left); + ReadMetadataUInt(frameReader.Get(), L"/imgdesc/Top", top); + ReadMetadataUInt(frameReader.Get(), L"/grctlext/Delay", delay); + ReadMetadataUInt(frameReader.Get(), L"/grctlext/Disposal", disposal); + FrameStep step; + if (!CopyPremultipliedBgra(factory, frame.Get(), limits.maxDimension, step.width, step.height, step.pixels)) + { + return false; + } + step.left = left; + step.top = top; + // Transparent GIF pixels leave the canvas alone. + step.over = true; + step.dispose = disposal == 2 ? Dispose::Background : disposal == 3 ? Dispose::Previous : Dispose::None; + out.frames_.push_back(ComposeStep(canvas, step, index == 0)); + out.timeline_.frameDelaysMs.push_back(NormalizeFrameDelayMs(delay * 10U)); + } + out.width_ = width; + out.height_ = height; + out.timeline_.playCount = playCount; + out.format_ = AnimatedImage::Format::Gif; + return true; + } + + // Multi-frame containers without frame timing in WIC (animated WebP when the codec exposes the + // frames): every frame is full size and replaces the previous one every 100 ms, forever. + static bool DecodeMultiFrame(IWICImagingFactory *factory, IWICBitmapDecoder *decoder, + const AnimatedImage::Limits &limits, AnimatedImage &out) + { + UINT count = 0; + if (FAILED(decoder->GetFrameCount(&count)) || count < 2 || count > limits.maxFrames) + { + return false; + } + out.frames_.clear(); + out.timeline_ = {}; + for (UINT index = 0; index < count; ++index) + { + ComPtr frame; + uint32_t width = 0; + uint32_t height = 0; + std::vector pixels; + if (FAILED(decoder->GetFrame(index, frame.GetAddressOf())) || + !CopyPremultipliedBgra(factory, frame.Get(), limits.maxDimension, width, height, pixels) || + (index > 0 && (width != out.width_ || height != out.height_)) || + !FitsLimits(width, height, count, limits)) + { + return false; + } + out.width_ = width; + out.height_ = height; + out.frames_.push_back(std::move(pixels)); + out.timeline_.frameDelaysMs.push_back(100U); + } + out.format_ = AnimatedImage::Format::MultiFrame; + return true; + } + + static bool DecodeStill(IWICImagingFactory *factory, IWICBitmapDecoder *decoder, + const AnimatedImage::Limits &limits, AnimatedImage &out) + { + ComPtr frame; + std::vector pixels; + uint32_t width = 0; + uint32_t height = 0; + if (FAILED(decoder->GetFrame(0, frame.GetAddressOf())) || + !CopyPremultipliedBgra(factory, frame.Get(), limits.maxDimension, width, height, pixels)) + { + return false; + } + out.width_ = width; + out.height_ = height; + out.frames_.clear(); + out.frames_.push_back(std::move(pixels)); + out.timeline_ = {}; + out.format_ = AnimatedImage::Format::Still; + return true; + } +}; + +uint32_t NormalizeFrameDelayMs(uint32_t delayMs) +{ + return delayMs <= 10U ? 100U : delayMs; +} + +uint64_t AnimationTimeline::LoopDurationMs() const +{ + uint64_t total = 0; + for (const uint32_t delay : frameDelaysMs) + { + total += delay; + } + return total; +} + +AnimationTimeline::Sample AnimationTimeline::SampleAt(uint64_t elapsedMs) const +{ + if (frameDelaysMs.size() <= 1) + { + return {}; + } + const size_t last = frameDelaysMs.size() - 1; + const uint64_t loop = LoopDurationMs(); + if (loop == 0) + { + return {}; + } + const uint64_t play = elapsedMs / loop; + if (playCount != 0 && play >= playCount) + { + return {last, std::nullopt}; + } + const uint64_t loopStart = play * loop; + const uint64_t intoLoop = elapsedMs - loopStart; + uint64_t frameEnd = 0; + for (size_t index = 0; index <= last; ++index) + { + frameEnd += frameDelaysMs[index]; + if (intoLoop < frameEnd) + { + const bool finalFrame = playCount != 0 && play + 1 == playCount && index == last; + if (finalFrame) + { + return {index, std::nullopt}; + } + return {index, loopStart + frameEnd}; + } + } + return {last, std::nullopt}; +} + +std::optional AnimatedImage::DecodeMemory(IWICImagingFactory *factory, const uint8_t *data, size_t size, + const Limits &limits) +{ + if (!factory || !data || size == 0 || size > limits.maxFileBytes) + { + return std::nullopt; + } + AnimatedImage image; + if (HasPngSignature(data, size) && AnimatedImageDecoder::DecodeApng(factory, data, size, limits, image)) + { + return image; + } + ComPtr decoder = CreateDecoder(factory, data, size); + if (!decoder) + { + return std::nullopt; + } + GUID container = {}; + if (SUCCEEDED(decoder->GetContainerFormat(&container))) + { + if (IsEqualGUID(container, GUID_ContainerFormatGif) && + AnimatedImageDecoder::DecodeGif(factory, decoder.Get(), limits, image)) + { + return image; + } + if (IsEqualGUID(container, GUID_ContainerFormatWebp) && + AnimatedImageDecoder::DecodeMultiFrame(factory, decoder.Get(), limits, image)) + { + return image; + } + } + if (AnimatedImageDecoder::DecodeStill(factory, decoder.Get(), limits, image)) + { + return image; + } + return std::nullopt; +} + +std::optional AnimatedImage::DecodeFile(IWICImagingFactory *factory, const std::wstring &filePath, + const Limits &limits) +{ + std::vector bytes; + if (!ReadFileBytes(filePath, limits.maxFileBytes, bytes)) + { + return std::nullopt; + } + return DecodeMemory(factory, bytes.data(), bytes.size(), limits); +} + +bool AnimatedImage::MayBeAnimated(IWICImagingFactory *factory, const std::wstring &filePath) +{ + std::vector bytes; + if (!factory || !ReadFileBytes(filePath, Limits().maxFileBytes, bytes)) + { + return false; + } + if (HasPngSignature(bytes.data(), bytes.size())) + { + // acTL has to come before the first IDAT; no need to look further. + size_t pos = sizeof(kPngSignature); + while (pos + 12 <= bytes.size()) + { + const uint32_t length = ReadBE32(bytes.data() + pos); + const uint32_t type = ReadBE32(bytes.data() + pos + 4); + if (type == kActl) + { + return true; + } + if (type == kIdat || type == kIend || length > bytes.size() - pos - 12) + { + return false; + } + pos += 12 + static_cast(length); + } + return false; + } + ComPtr decoder = CreateDecoder(factory, bytes.data(), bytes.size()); + GUID container = {}; + UINT count = 0; + return decoder && SUCCEEDED(decoder->GetContainerFormat(&container)) && + (IsEqualGUID(container, GUID_ContainerFormatGif) || IsEqualGUID(container, GUID_ContainerFormatWebp)) && + SUCCEEDED(decoder->GetFrameCount(&count)) && count > 1; +} +} // namespace msimeui diff --git a/ui/src/DeviceResources.cpp b/ui/src/DeviceResources.cpp index cac8c674..43dcca73 100644 --- a/ui/src/DeviceResources.cpp +++ b/ui/src/DeviceResources.cpp @@ -90,6 +90,176 @@ void DeviceResources::BeginFrame() ++frame_; clipStack_.clear(); TrimBrushCache(); + frameTime_ = AnimationClock::now(); + nextAnimationDeadline_.reset(); +} + +void DeviceResources::SetAnimationsEnabled(bool enabled) +{ + animationsEnabled_ = enabled; + if (!enabled) + { + nextAnimationDeadline_.reset(); + } +} + +bool DeviceResources::AreAnimationsEnabled() const +{ + return animationsEnabled_; +} + +void DeviceResources::SetAnimationEpoch(AnimationClock::time_point epoch) +{ + animationEpoch_ = epoch; +} + +void DeviceResources::SetFrameTime(AnimationClock::time_point now) +{ + frameTime_ = now; +} + +std::optional DeviceResources::GetNextAnimationDeadline() const +{ + return nextAnimationDeadline_; +} + +void DeviceResources::ClearImageCaches() +{ + bitmapCache_.clear(); + animatedCache_.clear(); + animatedBytes_ = 0; + stillImagePaths_.clear(); +} + +void DeviceResources::SetAnimatedBitmapBudget(size_t bytes) +{ + animatedBudget_ = bytes; + TrimAnimatedBitmaps(0); +} + +size_t DeviceResources::GetAnimatedBitmapBytes() const +{ + return animatedBytes_; +} + +void DeviceResources::RememberStillImage(const std::wstring &filePath) +{ + if (std::find(stillImagePaths_.begin(), stillImagePaths_.end(), filePath) != stillImagePaths_.end()) + { + return; + } + if (stillImagePaths_.size() >= kMaxRememberedStillImages) + { + stillImagePaths_.erase(stillImagePaths_.begin()); + } + stillImagePaths_.push_back(filePath); +} + +void DeviceResources::TrimAnimatedBitmaps(size_t incoming) +{ + // Least recently drawn first. Bitmaps already handed to the render target this frame stay + // alive inside Direct2D until the batch is flushed, so dropping our reference is safe. + while (!animatedCache_.empty() && animatedBytes_ + incoming > animatedBudget_) + { + auto oldest = std::min_element( + animatedCache_.begin(), animatedCache_.end(), + [](const AnimatedBitmapEntry &a, const AnimatedBitmapEntry &b) { return a.lastFrame < b.lastFrame; }); + animatedBytes_ -= oldest->bytes; + animatedCache_.erase(oldest); + } +} + +DeviceResources::AnimatedBitmapEntry *DeviceResources::FindOrLoadAnimation(const std::wstring &filePath) +{ + for (auto &entry : animatedCache_) + { + if (entry.filePath == filePath) + { + return &entry; + } + } + if (std::find(stillImagePaths_.begin(), stillImagePaths_.end(), filePath) != stillImagePaths_.end()) + { + return nullptr; + } + ID2D1RenderTarget *target = GetRenderTarget(); + if (!target || !wicFactory_) + { + return nullptr; + } + // A cheap look at the container first: still images never pay for a full decode here. + if (!AnimatedImage::MayBeAnimated(wicFactory_.Get(), filePath)) + { + RememberStillImage(filePath); + return nullptr; + } + std::optional image = AnimatedImage::DecodeFile(wicFactory_.Get(), filePath); + // Malformed animations decode to their still frame; an animation larger than the whole + // budget is shown still rather than evicting everything else. + if (!image || !image->IsAnimated() || image->DecodedBytes() > animatedBudget_) + { + RememberStillImage(filePath); + return nullptr; + } + AnimatedBitmapEntry entry; + entry.filePath = filePath; + entry.timeline = image->Timeline(); + entry.bytes = static_cast(image->DecodedBytes()); + const D2D1_BITMAP_PROPERTIES properties = D2D1::BitmapProperties( + D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED), 96.0f, 96.0f); + for (size_t index = 0; index < image->FrameCount(); ++index) + { + Microsoft::WRL::ComPtr bitmap; + if (FAILED(target->CreateBitmap(D2D1::SizeU(image->Width(), image->Height()), image->FramePixels(index).data(), + image->Width() * 4, &properties, bitmap.GetAddressOf()))) + { + return nullptr; + } + entry.frames.push_back(std::move(bitmap)); + } + entry.size = D2D1::SizeF(static_cast(image->Width()), static_cast(image->Height())); + TrimAnimatedBitmaps(entry.bytes); + animatedBytes_ += entry.bytes; + animatedCache_.push_back(std::move(entry)); + return &animatedCache_.back(); +} + +ID2D1Bitmap *DeviceResources::GetImageFrame(const std::wstring &filePath, D2D1_SIZE_F *size) +{ + if (filePath.empty() || !GetRenderTarget()) + { + return nullptr; + } + // Without animations an image is exactly what a still decoder shows: the APNG default image, + // the first GIF frame. Nothing animated is decoded or kept on the GPU for it. + if (!animationsEnabled_) + { + return GetBitmapFromFile(filePath, size); + } + AnimatedBitmapEntry *entry = FindOrLoadAnimation(filePath); + if (!entry) + { + return GetBitmapFromFile(filePath, size); + } + entry->lastFrame = frame_; + if (size) + { + *size = entry->size; + } + const AnimationClock::time_point now = frameTime_.value_or(AnimationClock::now()); + const auto elapsed = std::chrono::duration_cast(now - animationEpoch_).count(); + const AnimationTimeline::Sample sample = + entry->timeline.SampleAt(static_cast((std::max)(elapsed, static_cast(0)))); + if (sample.nextChangeMs) + { + const AnimationClock::time_point deadline = + animationEpoch_ + std::chrono::milliseconds(static_cast(*sample.nextChangeMs)); + if (!nextAnimationDeadline_ || deadline < *nextAnimationDeadline_) + { + nextAnimationDeadline_ = deadline; + } + } + return entry->frames[(std::min)(sample.frameIndex, entry->frames.size() - 1)].Get(); } void DeviceResources::TrimBrushCache() @@ -445,6 +615,8 @@ void DeviceResources::DiscardTarget() brushCache_.clear(); bitmapCache_.clear(); svgCache_.clear(); + animatedCache_.clear(); + animatedBytes_ = 0; clipStack_.clear(); composition_ = false; } diff --git a/ui/src/Image.cpp b/ui/src/Image.cpp index cd6eeb99..1a425711 100644 --- a/ui/src/Image.cpp +++ b/ui/src/Image.cpp @@ -31,6 +31,31 @@ SizeF ReadImageSize(const std::wstring &filePath) return {}; } + // A GIF frame can be smaller than the animation; its logical screen is the image size. + GUID container = {}; + ComPtr reader; + if (SUCCEEDED(decoder->GetContainerFormat(&container)) && IsEqualGUID(container, GUID_ContainerFormatGif) && + SUCCEEDED(decoder->GetMetadataQueryReader(reader.GetAddressOf()))) + { + PROPVARIANT width; + PROPVARIANT height; + PropVariantInit(&width); + PropVariantInit(&height); + SizeF size; + if (SUCCEEDED(reader->GetMetadataByName(L"/logscrdesc/Width", &width)) && width.vt == VT_UI2 && + SUCCEEDED(reader->GetMetadataByName(L"/logscrdesc/Height", &height)) && height.vt == VT_UI2 && + width.uiVal > 0 && height.uiVal > 0) + { + size = {static_cast(width.uiVal), static_cast(height.uiVal)}; + } + PropVariantClear(&width); + PropVariantClear(&height); + if (size.width > 0.0f) + { + return size; + } + } + UINT width = 0; UINT height = 0; return SUCCEEDED(frame->GetSize(&width, &height)) ? SizeF{static_cast(width), static_cast(height)} @@ -154,7 +179,7 @@ void Image::Arrange(const RectF &finalRect) void Image::Render(DeviceResources &deviceResources) { D2D1_SIZE_F sourceSize = {}; - ID2D1Bitmap *bitmap = deviceResources.GetBitmapFromFile(filePath_, &sourceSize); + ID2D1Bitmap *bitmap = deviceResources.GetImageFrame(filePath_, &sourceSize); ID2D1RenderTarget *target = deviceResources.GetRenderTarget(); if (!bitmap || !target || sourceSize.width <= 0.0f || sourceSize.height <= 0.0f || bounds_.width <= 0.0f || bounds_.height <= 0.0f) diff --git a/ui/src/Layout.cpp b/ui/src/Layout.cpp index 32af8f32..45de3451 100644 --- a/ui/src/Layout.cpp +++ b/ui/src/Layout.cpp @@ -2142,25 +2142,173 @@ void Card::SetBackgroundImage(std::wstring filePath, ImageStretch stretch, float InvalidateVisual(); } -void Card::SetBackgroundImageNineSlice(std::wstring filePath, float left, float top, float right, float bottom, - float opacity) +namespace +{ +Thickness NonNegative(const Thickness &value) +{ + return {(std::max)(0.0f, value.left), (std::max)(0.0f, value.top), (std::max)(0.0f, value.right), + (std::max)(0.0f, value.bottom)}; +} + +bool SameThickness(const Thickness &a, const Thickness &b) +{ + return a.left == b.left && a.top == b.top && a.right == b.right && a.bottom == b.bottom; +} + +// Moves a DIP coordinate onto the nearest device pixel under the target's current transform, +// when that transform only scales and translates. +struct PixelSnapper +{ + explicit PixelSnapper(ID2D1RenderTarget *target) + { + float dpiX = 96.0f; + float dpiY = 96.0f; + target->GetDpi(&dpiX, &dpiY); + D2D1_MATRIX_3X2_F transform = {}; + target->GetTransform(&transform); + enabled = transform._12 == 0.0f && transform._21 == 0.0f && transform._11 > 0.0f && transform._22 > 0.0f && + dpiX > 0.0f && dpiY > 0.0f; + scaleX = transform._11 * dpiX / 96.0f; + scaleY = transform._22 * dpiY / 96.0f; + offsetX = transform._31 * dpiX / 96.0f; + offsetY = transform._32 * dpiY / 96.0f; + } + + float X(float value) const + { + return enabled ? (std::round(value * scaleX + offsetX) - offsetX) / scaleX : value; + } + + float Y(float value) const + { + return enabled ? (std::round(value * scaleY + offsetY) - offsetY) / scaleY : value; + } + + bool enabled = false; + float scaleX = 1.0f; + float scaleY = 1.0f; + float offsetX = 0.0f; + float offsetY = 0.0f; +}; +} // namespace + +void DrawNineSlice(ID2D1RenderTarget *target, ID2D1Bitmap *bitmap, const RectF &destination, const NineSlice &nineSlice, + float opacity) +{ + if (!target || !bitmap || destination.width <= 0.0f || destination.height <= 0.0f || opacity <= 0.0f) + { + return; + } + // Source rectangles are in the bitmap's DIPs; slices are image pixels. + const D2D1_SIZE_U pixels = bitmap->GetPixelSize(); + const D2D1_SIZE_F dips = bitmap->GetSize(); + if (pixels.width == 0 || pixels.height == 0 || dips.width <= 0.0f || dips.height <= 0.0f) + { + return; + } + const float srcW = static_cast(pixels.width); + const float srcH = static_cast(pixels.height); + const float toDipX = dips.width / srcW; + const float toDipY = dips.height / srcH; + const Thickness slice = NonNegative(nineSlice.slice); + // A slice past the image edge means the whole image (CSS). + const float sl = (std::min)(slice.left, srcW); + const float st = (std::min)(slice.top, srcH); + const float sr = (std::min)(slice.right, srcW); + const float sb = (std::min)(slice.bottom, srcH); + + Thickness width = NonNegative(nineSlice.width); + // CSS border-image-width: when opposite widths overlap, all four shrink by one factor. + float factor = 1.0f; + if (width.left + width.right > destination.width) + { + factor = (std::min)(factor, destination.width / (width.left + width.right)); + } + if (width.top + width.bottom > destination.height) + { + factor = (std::min)(factor, destination.height / (width.top + width.bottom)); + } + width = {width.left * factor, width.top * factor, width.right * factor, width.bottom * factor}; + + const PixelSnapper snap(target); + const float x0 = snap.X(destination.x); + const float x3 = snap.X(destination.x + destination.width); + const float x1 = (std::min)(snap.X(destination.x + width.left), x3); + const float x2 = (std::max)(snap.X(destination.x + destination.width - width.right), x1); + const float y0 = snap.Y(destination.y); + const float y3 = snap.Y(destination.y + destination.height); + const float y1 = (std::min)(snap.Y(destination.y + width.top), y3); + const float y2 = (std::max)(snap.Y(destination.y + destination.height - width.bottom), y1); + + const float dx[4] = {x0, x1, x2, x3}; + const float dy[4] = {y0, y1, y2, y3}; + // When the left and right slices meet or cross, the top, bottom and middle parts are empty. + const float sourceLeft[3] = {0.0f, sl, srcW - sr}; + const float sourceRight[3] = {sl, srcW - sr, srcW}; + const float sourceTop[3] = {0.0f, st, srcH - sb}; + const float sourceBottom[3] = {st, srcH - sb, srcH}; + // DrawBitmap with a source rectangle still samples across its edges when it scales, which + // bleeds the neighbouring part into every seam. An image brush clamped to the part does not. + ComPtr context; + target->QueryInterface(IID_PPV_ARGS(context.GetAddressOf())); + for (int row = 0; row < 3; ++row) + { + for (int col = 0; col < 3; ++col) + { + if (row == 1 && col == 1 && !nineSlice.fill) + { + continue; + } + const float sourceWidth = sourceRight[col] - sourceLeft[col]; + const float sourceHeight = sourceBottom[row] - sourceTop[row]; + if (sourceWidth <= 0.0f || sourceHeight <= 0.0f || dx[col + 1] <= dx[col] || dy[row + 1] <= dy[row]) + { + continue; + } + const D2D1_RECT_F source = D2D1::RectF(sourceLeft[col] * toDipX, sourceTop[row] * toDipY, + sourceRight[col] * toDipX, sourceBottom[row] * toDipY); + const D2D1_RECT_F part = D2D1::RectF(dx[col], dy[row], dx[col + 1], dy[row + 1]); + ComPtr brush; + if (context) + { + // An image brush puts the top-left of its source rectangle at the brush origin. + const float scaleX = (part.right - part.left) / (source.right - source.left); + const float scaleY = (part.bottom - part.top) / (source.bottom - source.top); + const D2D1_MATRIX_3X2_F transform = + D2D1::Matrix3x2F::Scale(scaleX, scaleY) * D2D1::Matrix3x2F::Translation(part.left, part.top); + context->CreateImageBrush(bitmap, + D2D1::ImageBrushProperties(source, D2D1_EXTEND_MODE_CLAMP, + D2D1_EXTEND_MODE_CLAMP, + D2D1_INTERPOLATION_MODE_LINEAR), + D2D1::BrushProperties(opacity, transform), brush.GetAddressOf()); + } + if (brush) + { + context->FillRectangle(part, brush.Get()); + } + else + { + target->DrawBitmap(bitmap, part, opacity, D2D1_BITMAP_INTERPOLATION_MODE_LINEAR, &source); + } + } + } +} + +void Card::SetBackgroundNineSlice(std::wstring filePath, const NineSlice &nineSlice, float opacity) { const float clamped = std::clamp(opacity, 0.0f, 1.0f); - const float l = (std::max)(0.0f, left); - const float t = (std::max)(0.0f, top); - const float r = (std::max)(0.0f, right); - const float b = (std::max)(0.0f, bottom); - if (backgroundImage_ == filePath && backgroundNineSlice_ && sliceLeft_ == l && sliceTop_ == t && sliceRight_ == r && - sliceBottom_ == b && backgroundOpacity_ == clamped) + NineSlice normalized = nineSlice; + normalized.slice = NonNegative(nineSlice.slice); + normalized.width = NonNegative(nineSlice.width); + if (backgroundImage_ == filePath && backgroundNineSlice_ && SameThickness(nineSlice_.slice, normalized.slice) && + SameThickness(nineSlice_.width, normalized.width) && nineSlice_.fill == normalized.fill && + backgroundOpacity_ == clamped) { return; } backgroundImage_ = std::move(filePath); backgroundNineSlice_ = true; - sliceLeft_ = l; - sliceTop_ = t; - sliceRight_ = r; - sliceBottom_ = b; + nineSlice_ = normalized; backgroundOpacity_ = clamped; InvalidateVisual(); } @@ -2173,11 +2321,17 @@ void Card::RenderBackgroundImage(DeviceResources &deviceResources, ID2D1RenderTa return; } D2D1_SIZE_F sourceSize = {}; - ID2D1Bitmap *bitmap = deviceResources.GetBitmapFromFile(backgroundImage_, &sourceSize); + ID2D1Bitmap *bitmap = deviceResources.GetImageFrame(backgroundImage_, &sourceSize); if (!bitmap || sourceSize.width <= 0.0f || sourceSize.height <= 0.0f) { return; } + if (backgroundNineSlice_) + { + // Like CSS border-image, the panel image ignores the corner radius. + DrawNineSlice(target, bitmap, bounds_, nineSlice_, backgroundOpacity_); + return; + } // Same placement rules as Image: Uniform / UniformToFill center the scaled bitmap, // None keeps its natural size at the top-left corner. @@ -2210,44 +2364,6 @@ void Card::RenderBackgroundImage(DeviceResources &deviceResources, ID2D1RenderTa return; } target->PushLayer(D2D1::LayerParameters(D2D1::InfiniteRect(), clip.Get()), layer.Get()); - if (backgroundNineSlice_) - { - // 九宫格:四角原样、四边单向拉伸、中心双向拉伸。切片单位与源图像素一致, - // 图像按 96 DPI 绘制时 1 源像素 = 1 DIP。 - const float srcW = sourceSize.width; - const float srcH = sourceSize.height; - const float l = (std::min)(sliceLeft_, srcW); - const float t = (std::min)(sliceTop_, srcH); - const float r = (std::min)(sliceRight_, srcW - l); - const float b = (std::min)(sliceBottom_, srcH - t); - const float dl = (std::min)(l, bounds_.width); - const float dt = (std::min)(t, bounds_.height); - const float dr = (std::min)(r, bounds_.width - dl); - const float db = (std::min)(b, bounds_.height - dt); - const float sx[3] = {0.0f, l, srcW - r}; - const float sw[3] = {l, srcW - l - r, r}; - const float dx[3] = {bounds_.x, bounds_.x + dl, bounds_.x + bounds_.width - dr}; - const float dw[3] = {dl, bounds_.width - dl - dr, dr}; - const float sy[3] = {0.0f, t, srcH - b}; - const float sh[3] = {t, srcH - t - b, b}; - const float dy[3] = {bounds_.y, bounds_.y + dt, bounds_.y + bounds_.height - db}; - const float dh[3] = {dt, bounds_.height - dt - db, db}; - for (int row = 0; row < 3; ++row) - { - for (int col = 0; col < 3; ++col) - { - if (sw[col] <= 0.0f || sh[row] <= 0.0f || dw[col] <= 0.0f || dh[row] <= 0.0f) - { - continue; - } - target->DrawBitmap(bitmap, D2D1::RectF(dx[col], dy[row], dx[col] + dw[col], dy[row] + dh[row]), - backgroundOpacity_, D2D1_BITMAP_INTERPOLATION_MODE_LINEAR, - D2D1::RectF(sx[col], sy[row], sx[col] + sw[col], sy[row] + sh[row])); - } - } - target->PopLayer(); - return; - } target->DrawBitmap(bitmap, D2D1::RectF(destination.x, destination.y, destination.x + destination.width, destination.y + destination.height), diff --git a/ui/src/Scene.cpp b/ui/src/Scene.cpp index 006f7762..189a98a8 100644 --- a/ui/src/Scene.cpp +++ b/ui/src/Scene.cpp @@ -360,6 +360,12 @@ void Scene::Render(DeviceResources &deviceResources) popup.visual->Render(deviceResources); } } + nextFrameDeadline_ = deviceResources.GetNextAnimationDeadline(); +} + +std::optional Scene::GetNextFrameDeadline() const +{ + return nextFrameDeadline_; } Visual *Scene::FindVisualAt(const PointF &point) diff --git a/ui/src/Window.cpp b/ui/src/Window.cpp index 7973c6ba..e09c50fa 100644 --- a/ui/src/Window.cpp +++ b/ui/src/Window.cpp @@ -6,6 +6,7 @@ #include "DebugLog.h" #include +#include #include #include #include @@ -579,6 +580,12 @@ LRESULT Window::HandleMessage(UINT message, WPARAM wParam, LPARAM lParam) return 0; case WM_TIMER: + if (wParam == kImageAnimationTimerId) + { + KillTimer(hwnd_, kImageAnimationTimerId); + Invalidate(); + return 0; + } if (focusedVisual_ && focusedVisual_->OnTimer(wParam)) { return 0; @@ -629,6 +636,7 @@ LRESULT Window::HandleMessage(UINT message, WPARAM wParam, LPARAM lParam) case WM_DESTROY: KillTimer(hwnd_, 1); + KillTimer(hwnd_, kImageAnimationTimerId); if (quitOnDestroy_) { PostQuitMessage(0); @@ -683,6 +691,21 @@ void Window::OnPaint() painting_ = false; EndPaint(hwnd_, &paint); + // Animated images change at their own frame times, far apart compared with the display + // rate, so they get a one-shot timer for the next change instead of a frame loop. + const auto imageDeadline = deviceResources_.GetNextAnimationDeadline(); + // An adopted HWND belongs to a host that may not route WM_TIMER back here; it schedules + // its own frames from the same deadline. + if (imageDeadline && !animationRequested_ && !adoptedHwnd_) + { + const auto wait = + std::chrono::ceil(*imageDeadline - DeviceResources::AnimationClock::now()); + SetTimer(hwnd_, kImageAnimationTimerId, static_cast((std::max)(wait.count(), 1LL)), nullptr); + } + else + { + KillTimer(hwnd_, kImageAnimationTimerId); + } if (animationRequested_) { // The HWND target presents in step with the display; a composition swap chain diff --git a/ui/tests/CMakeLists.txt b/ui/tests/CMakeLists.txt index 986f6040..d7cfc78f 100644 --- a/ui/tests/CMakeLists.txt +++ b/ui/tests/CMakeLists.txt @@ -2,6 +2,7 @@ set(TEST_EXECUTABLE_NAME "msimeui-tests") add_executable(${TEST_EXECUTABLE_NAME} src/main.cpp + src/test_animated_image.cpp src/test_animation.cpp src/test_card_shadow.cpp src/test_device_resources.cpp diff --git a/ui/tests/src/test_animated_image.cpp b/ui/tests/src/test_animated_image.cpp new file mode 100644 index 00000000..7b9f9173 --- /dev/null +++ b/ui/tests/src/test_animated_image.cpp @@ -0,0 +1,887 @@ +#include "tests/includes/test_framework.h" + +#include "msimeui/AnimatedImage.h" +#include "msimeui/DeviceResources.h" +#include "msimeui/Layout.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +using Microsoft::WRL::ComPtr; + +namespace +{ +struct Rgba +{ + uint8_t r = 0; + uint8_t g = 0; + uint8_t b = 0; + uint8_t a = 0; +}; + +constexpr Rgba kRed = {255, 0, 0, 255}; +constexpr Rgba kGreen = {0, 255, 0, 255}; +constexpr Rgba kBlue = {0, 0, 255, 255}; +constexpr Rgba kClear = {0, 0, 0, 0}; + +class ComApartment +{ + public: + ComApartment() : hr_(CoInitializeEx(nullptr, COINIT_APARTMENTTHREADED)) + { + REQUIRE(SUCCEEDED(hr_) || hr_ == RPC_E_CHANGED_MODE); + } + + ~ComApartment() + { + if (SUCCEEDED(hr_)) + { + CoUninitialize(); + } + } + + private: + HRESULT hr_; +}; + +ComPtr CreateFactory() +{ + ComPtr factory; + REQUIRE(SUCCEEDED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(factory.GetAddressOf())))); + return factory; +} + +// ---- PNG / APNG fixtures, written chunk by chunk with stored (uncompressed) deflate blocks ---- + +void PutBE32(std::vector &out, uint32_t value) +{ + for (int shift = 24; shift >= 0; shift -= 8) + { + out.push_back(static_cast(value >> shift)); + } +} + +void PutBE16(std::vector &out, uint16_t value) +{ + out.push_back(static_cast(value >> 8)); + out.push_back(static_cast(value)); +} + +void PutLE16(std::vector &out, uint16_t value) +{ + out.push_back(static_cast(value)); + out.push_back(static_cast(value >> 8)); +} + +uint32_t Crc32(const uint8_t *data, size_t size) +{ + uint32_t crc = 0xFFFFFFFFU; + for (size_t i = 0; i < size; ++i) + { + crc ^= data[i]; + for (int k = 0; k < 8; ++k) + { + crc = (crc & 1U) ? 0xEDB88320U ^ (crc >> 1) : crc >> 1; + } + } + return crc ^ 0xFFFFFFFFU; +} + +void PutChunk(std::vector &out, const char *type, const std::vector &data) +{ + PutBE32(out, static_cast(data.size())); + std::vector body(type, type + 4); + body.insert(body.end(), data.begin(), data.end()); + out.insert(out.end(), body.begin(), body.end()); + PutBE32(out, Crc32(body.data(), body.size())); +} + +std::vector ZlibStored(const std::vector &raw) +{ + std::vector out = {0x78, 0x01}; + size_t pos = 0; + do + { + const size_t n = (std::min)(raw.size() - pos, size_t{65535}); + const bool last = pos + n == raw.size(); + out.push_back(last ? 1 : 0); + PutLE16(out, static_cast(n)); + PutLE16(out, static_cast(~n)); + out.insert(out.end(), raw.begin() + static_cast(pos), + raw.begin() + static_cast(pos + n)); + pos += n; + } while (pos < raw.size()); + uint32_t a = 1; + uint32_t b = 0; + for (const uint8_t byte : raw) + { + a = (a + byte) % 65521U; + b = (b + a) % 65521U; + } + PutBE32(out, (b << 16) | a); + return out; +} + +std::vector RgbaScanlines(uint32_t width, uint32_t height, const std::vector &pixels) +{ + std::vector raw; + for (uint32_t y = 0; y < height; ++y) + { + raw.push_back(0); // filter: none + for (uint32_t x = 0; x < width; ++x) + { + const Rgba &p = pixels[y * width + x]; + raw.insert(raw.end(), {p.r, p.g, p.b, p.a}); + } + } + return ZlibStored(raw); +} + +std::vector Fill(uint32_t width, uint32_t height, Rgba color) +{ + return std::vector(static_cast(width) * height, color); +} + +struct ApngFrame +{ + uint32_t width = 0; + uint32_t height = 0; + uint32_t x = 0; + uint32_t y = 0; + uint16_t delayNum = 1; + uint16_t delayDen = 10; + uint8_t dispose = 0; + uint8_t blend = 0; + std::vector pixels; +}; + +struct ApngOptions +{ + uint32_t plays = 0; + // A still IDAT image that is not part of the animation (no fcTL before it). + std::optional> separateDefaultImage; + // Leaves out the fdAT of this frame index. + std::optional dropDataOf; + // Declared frame count; defaults to the number of frames. + std::optional declaredFrames; +}; + +std::vector Ihdr(uint32_t width, uint32_t height) +{ + std::vector ihdr; + PutBE32(ihdr, width); + PutBE32(ihdr, height); + ihdr.insert(ihdr.end(), {8, 6, 0, 0, 0}); // 8-bit RGBA, deflate, adaptive filter, no interlace + return ihdr; +} + +std::vector Fctl(uint32_t sequence, const ApngFrame &frame) +{ + std::vector data; + PutBE32(data, sequence); + PutBE32(data, frame.width); + PutBE32(data, frame.height); + PutBE32(data, frame.x); + PutBE32(data, frame.y); + PutBE16(data, frame.delayNum); + PutBE16(data, frame.delayDen); + data.push_back(frame.dispose); + data.push_back(frame.blend); + return data; +} + +std::vector BuildApng(uint32_t width, uint32_t height, const std::vector &frames, + const ApngOptions &options = {}) +{ + std::vector png = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}; + PutChunk(png, "IHDR", Ihdr(width, height)); + std::vector actl; + PutBE32(actl, options.declaredFrames.value_or(static_cast(frames.size()))); + PutBE32(actl, options.plays); + PutChunk(png, "acTL", actl); + uint32_t sequence = 0; + size_t first = 0; + if (options.separateDefaultImage) + { + PutChunk(png, "IDAT", RgbaScanlines(width, height, *options.separateDefaultImage)); + } + else + { + PutChunk(png, "fcTL", Fctl(sequence++, frames[0])); + PutChunk(png, "IDAT", RgbaScanlines(frames[0].width, frames[0].height, frames[0].pixels)); + first = 1; + } + for (size_t i = first; i < frames.size(); ++i) + { + PutChunk(png, "fcTL", Fctl(sequence++, frames[i])); + if (options.dropDataOf == i) + { + continue; + } + std::vector fdat; + PutBE32(fdat, sequence++); + const std::vector data = RgbaScanlines(frames[i].width, frames[i].height, frames[i].pixels); + fdat.insert(fdat.end(), data.begin(), data.end()); + PutChunk(png, "fdAT", fdat); + } + PutChunk(png, "IEND", {}); + return png; +} + +// Byte offset of the n-th chunk of `type`, pointing at its length field. +size_t FindChunk(const std::vector &png, const char *type, int nth = 0) +{ + size_t pos = 8; + while (pos + 12 <= png.size()) + { + const uint32_t length = (static_cast(png[pos]) << 24) | (static_cast(png[pos + 1]) << 16) | + (static_cast(png[pos + 2]) << 8) | png[pos + 3]; + if (std::equal(type, type + 4, png.begin() + static_cast(pos + 4)) && nth-- == 0) + { + return pos; + } + pos += 12 + length; + } + throw std::runtime_error("chunk not found"); +} + +// ---- GIF fixtures: 2-bit palette, LZW codes kept at 3 bits by clearing every two pixels ---- + +struct GifFrame +{ + uint16_t left = 0; + uint16_t top = 0; + uint16_t width = 0; + uint16_t height = 0; + uint16_t delayCs = 0; + uint8_t disposal = 0; + std::vector indices; // palette 0 red, 1 blue, 2 green, 3 transparent +}; + +std::vector LzwUncompressed(const std::vector &indices) +{ + std::vector out; + uint32_t buffer = 0; + int bits = 0; + const auto emit = [&](uint32_t code) { + buffer |= code << bits; + bits += 3; + while (bits >= 8) + { + out.push_back(static_cast(buffer)); + buffer >>= 8; + bits -= 8; + } + }; + constexpr uint32_t kClearCode = 4; + constexpr uint32_t kEndCode = 5; + for (size_t i = 0; i < indices.size(); ++i) + { + if (i % 2 == 0) + { + emit(kClearCode); + } + emit(indices[i]); + } + emit(kEndCode); + if (bits > 0) + { + out.push_back(static_cast(buffer)); + } + return out; +} + +std::vector BuildGif(uint16_t width, uint16_t height, const std::vector &frames, + std::optional loop) +{ + std::vector gif = {'G', 'I', 'F', '8', '9', 'a'}; + PutLE16(gif, width); + PutLE16(gif, height); + gif.insert(gif.end(), {0x81, 0x00, 0x00}); + gif.insert(gif.end(), {255, 0, 0, 0, 0, 255, 0, 255, 0, 0, 0, 0}); + if (loop) + { + gif.insert(gif.end(), {0x21, 0xFF, 0x0B}); + const char *app = "NETSCAPE2.0"; + gif.insert(gif.end(), app, app + 11); + gif.insert(gif.end(), {0x03, 0x01}); + PutLE16(gif, *loop); + gif.push_back(0x00); + } + for (const GifFrame &frame : frames) + { + gif.insert(gif.end(), {0x21, 0xF9, 0x04, static_cast((frame.disposal << 2) | 1)}); + PutLE16(gif, frame.delayCs); + gif.insert(gif.end(), {0x03, 0x00}); + gif.push_back(0x2C); + PutLE16(gif, frame.left); + PutLE16(gif, frame.top); + PutLE16(gif, frame.width); + PutLE16(gif, frame.height); + gif.push_back(0x00); + gif.push_back(0x02); // LZW minimum code size + const std::vector data = LzwUncompressed(frame.indices); + for (size_t pos = 0; pos < data.size(); pos += 255) + { + const size_t n = (std::min)(data.size() - pos, size_t{255}); + gif.push_back(static_cast(n)); + gif.insert(gif.end(), data.begin() + static_cast(pos), + data.begin() + static_cast(pos + n)); + } + gif.push_back(0x00); + } + gif.push_back(0x3B); + return gif; +} + +// ---- Inspection ---- + +Rgba PixelAt(const msimeui::AnimatedImage &image, size_t frame, uint32_t x, uint32_t y) +{ + const auto &pixels = image.FramePixels(frame); + const size_t offset = (static_cast(y) * image.Width() + x) * 4; + return {pixels[offset + 2], pixels[offset + 1], pixels[offset], pixels[offset + 3]}; +} + +bool Near(const Rgba &actual, const Rgba &expected, int tolerance = 1) +{ + const auto close = [tolerance](uint8_t a, uint8_t b) { return std::abs(static_cast(a) - b) <= tolerance; }; + const bool ok = close(actual.r, expected.r) && close(actual.g, expected.g) && close(actual.b, expected.b) && + close(actual.a, expected.a); + if (!ok) + { + std::printf("pixel %d,%d,%d,%d expected %d,%d,%d,%d\n", actual.r, actual.g, actual.b, actual.a, expected.r, + expected.g, expected.b, expected.a); + } + return ok; +} + +std::optional Decode(IWICImagingFactory *factory, const std::vector &bytes, + const msimeui::AnimatedImage::Limits &limits = {}) +{ + return msimeui::AnimatedImage::DecodeMemory(factory, bytes.data(), bytes.size(), limits); +} + +ApngFrame Frame(uint32_t width, uint32_t height, Rgba color, uint32_t x = 0, uint32_t y = 0) +{ + ApngFrame frame; + frame.width = width; + frame.height = height; + frame.x = x; + frame.y = y; + frame.pixels = Fill(width, height, color); + return frame; +} + +class TempFile +{ + public: + explicit TempFile(const std::vector &bytes, const wchar_t *extension) + { + wchar_t directory[MAX_PATH] = {}; + REQUIRE(GetTempPathW(MAX_PATH, directory) != 0); + static int counter = 0; + path = std::wstring(directory) + L"msimeui-anim-" + std::to_wstring(GetCurrentProcessId()) + L"-" + + std::to_wstring(++counter) + extension; + HANDLE file = + CreateFileW(path.c_str(), GENERIC_WRITE, 0, nullptr, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, nullptr); + REQUIRE(file != INVALID_HANDLE_VALUE); + DWORD written = 0; + const BOOL ok = WriteFile(file, bytes.data(), static_cast(bytes.size()), &written, nullptr); + CloseHandle(file); + REQUIRE(ok && written == bytes.size()); + } + + ~TempFile() + { + DeleteFileW(path.c_str()); + } + + std::wstring path; +}; + +class HiddenWindow +{ + public: + HiddenWindow() + : hwnd(CreateWindowExW(WS_EX_NOREDIRECTIONBITMAP, L"STATIC", L"Animated image test", WS_POPUP, 0, 0, 128, 128, + nullptr, nullptr, GetModuleHandleW(nullptr), nullptr)) + { + REQUIRE(hwnd != nullptr); + } + + ~HiddenWindow() + { + DestroyWindow(hwnd); + } + + HWND hwnd; +}; +} // namespace + +TEST_CASE(apng_decodes_every_frame_with_browser_delay_rules) +{ + ComApartment com; + auto factory = CreateFactory(); + std::vector frames = {Frame(4, 4, kRed), Frame(4, 4, kGreen), Frame(4, 4, kBlue), Frame(4, 4, kRed)}; + frames[0].delayNum = 1; // 1/20 s = 50 ms + frames[0].delayDen = 20; + frames[1].delayNum = 1; // 1/100 s = 10 ms, shown for 100 ms like browsers do + frames[1].delayDen = 100; + frames[2].delayNum = 30; // denominator 0 means 1/100: 300 ms + frames[2].delayDen = 0; + frames[3].delayNum = 0; // 0 ms, shown for 100 ms + frames[3].delayDen = 1; + const auto image = Decode(factory.Get(), BuildApng(4, 4, frames)); + REQUIRE(image.has_value()); + REQUIRE(image->GetFormat() == msimeui::AnimatedImage::Format::Apng); + REQUIRE(image->FrameCount() == 4); + REQUIRE(image->Width() == 4 && image->Height() == 4); + REQUIRE(image->Timeline().frameDelaysMs == std::vector({50, 100, 300, 100})); + REQUIRE(image->Timeline().playCount == 0); + REQUIRE(Near(PixelAt(*image, 0, 1, 1), kRed)); + REQUIRE(Near(PixelAt(*image, 1, 1, 1), kGreen)); + REQUIRE(Near(PixelAt(*image, 2, 1, 1), kBlue)); +} + +TEST_CASE(apng_places_sub_rect_frames_and_disposes_to_background) +{ + ComApartment com; + auto factory = CreateFactory(); + std::vector frames = {Frame(6, 6, kRed), Frame(2, 2, kBlue, 3, 1), Frame(1, 1, kGreen, 0, 0)}; + frames[1].dispose = 1; // APNG_DISPOSE_OP_BACKGROUND + const auto image = Decode(factory.Get(), BuildApng(6, 6, frames)); + REQUIRE(image.has_value() && image->FrameCount() == 3); + // Frame 1 covers only its rectangle; the rest still shows frame 0. + REQUIRE(Near(PixelAt(*image, 1, 3, 1), kBlue)); + REQUIRE(Near(PixelAt(*image, 1, 4, 2), kBlue)); + REQUIRE(Near(PixelAt(*image, 1, 2, 1), kRed)); + REQUIRE(Near(PixelAt(*image, 1, 5, 3), kRed)); + // After frame 1 its rectangle is cleared to transparent black. + REQUIRE(Near(PixelAt(*image, 2, 3, 1), kClear)); + REQUIRE(Near(PixelAt(*image, 2, 4, 2), kClear)); + REQUIRE(Near(PixelAt(*image, 2, 2, 2), kRed)); + REQUIRE(Near(PixelAt(*image, 2, 0, 0), kGreen)); +} + +TEST_CASE(apng_dispose_previous_restores_the_canvas_and_means_background_on_the_first_frame) +{ + ComApartment com; + auto factory = CreateFactory(); + std::vector frames = {Frame(4, 4, kRed), Frame(2, 2, kBlue, 1, 1), Frame(1, 1, kGreen, 3, 3)}; + frames[1].dispose = 2; // APNG_DISPOSE_OP_PREVIOUS + auto image = Decode(factory.Get(), BuildApng(4, 4, frames)); + REQUIRE(image.has_value() && image->FrameCount() == 3); + REQUIRE(Near(PixelAt(*image, 1, 1, 1), kBlue)); + REQUIRE(Near(PixelAt(*image, 2, 1, 1), kRed)); + REQUIRE(Near(PixelAt(*image, 2, 2, 2), kRed)); + REQUIRE(Near(PixelAt(*image, 2, 3, 3), kGreen)); + + // PREVIOUS on the first frame clears like BACKGROUND. + std::vector firstPrevious = {Frame(4, 4, kRed), Frame(1, 1, kGreen, 0, 0)}; + firstPrevious[0].dispose = 2; + image = Decode(factory.Get(), BuildApng(4, 4, firstPrevious)); + REQUIRE(image.has_value() && image->FrameCount() == 2); + REQUIRE(Near(PixelAt(*image, 0, 2, 2), kRed)); + REQUIRE(Near(PixelAt(*image, 1, 2, 2), kClear)); + REQUIRE(Near(PixelAt(*image, 1, 0, 0), kGreen)); +} + +TEST_CASE(apng_blend_over_composites_and_blend_source_replaces) +{ + ComApartment com; + auto factory = CreateFactory(); + const Rgba halfBlue = {0, 0, 255, 128}; + for (const uint8_t blend : {uint8_t{0}, uint8_t{1}}) + { + std::vector frames = {Frame(2, 2, kRed), Frame(2, 2, halfBlue)}; + frames[1].blend = blend; + const auto image = Decode(factory.Get(), BuildApng(2, 2, frames)); + REQUIRE(image.has_value() && image->FrameCount() == 2); + const Rgba pixel = PixelAt(*image, 1, 0, 0); + if (blend == 0) + { + // SOURCE: the half transparent blue replaces red (premultiplied: blue 128, alpha 128). + REQUIRE(Near(pixel, {0, 0, 128, 128})); + } + else + { + // OVER: blue 128 + red 255 * (1 - 128/255) = red 127, opaque. + REQUIRE(Near(pixel, {127, 0, 128, 255})); + } + } +} + +TEST_CASE(apng_default_image_outside_the_animation_is_skipped) +{ + ComApartment com; + auto factory = CreateFactory(); + ApngOptions options; + options.separateDefaultImage = Fill(3, 3, kGreen); + const auto image = Decode(factory.Get(), BuildApng(3, 3, {Frame(3, 3, kRed), Frame(3, 3, kBlue)}, options)); + REQUIRE(image.has_value()); + REQUIRE(image->FrameCount() == 2); + REQUIRE(Near(PixelAt(*image, 0, 1, 1), kRed)); + REQUIRE(Near(PixelAt(*image, 1, 1, 1), kBlue)); +} + +TEST_CASE(apng_malformed_streams_fall_back_to_the_still_image) +{ + ComApartment com; + auto factory = CreateFactory(); + const std::vector frames = {Frame(4, 4, kRed), Frame(2, 2, kBlue, 1, 1)}; + const auto expectStill = [&](const std::vector &bytes, const char *what) { + const auto image = Decode(factory.Get(), bytes); + std::printf("%s: %s, %zu frame(s)\n", what, image ? "decoded" : "rejected", image ? image->FrameCount() : 0); + // Either the still default image or nothing, but never an animation and never a crash. + REQUIRE(!image || (image->FrameCount() == 1 && !image->IsAnimated())); + return image; + }; + + // A bad CRC on an APNG chunk. + std::vector badCrc = BuildApng(4, 4, frames); + const size_t fctl = FindChunk(badCrc, "fcTL", 1); + badCrc[fctl + 8 + 26] ^= 0xFF; + const auto still = expectStill(badCrc, "bad crc"); + REQUIRE(still.has_value()); + REQUIRE(Near(PixelAt(*still, 0, 0, 0), kRed)); + + // A frame reaching past the canvas. + std::vector outside = frames; + outside[1].x = 3; + expectStill(BuildApng(4, 4, outside), "out of bounds"); + + // A frame without fdAT data. + ApngOptions missing; + missing.dropDataOf = 1; + expectStill(BuildApng(4, 4, frames, missing), "missing fdAT"); + + // acTL promising more frames than there are. + ApngOptions miscounted; + miscounted.declaredFrames = 3; + expectStill(BuildApng(4, 4, frames, miscounted), "frame count"); + + // Sequence numbers out of order. + std::vector reordered = BuildApng(4, 4, frames); + const size_t secondFctl = FindChunk(reordered, "fcTL", 1); + reordered[secondFctl + 8 + 3] = 7; + const uint32_t crc = Crc32(reordered.data() + secondFctl + 4, 4 + 26); + for (int b = 0; b < 4; ++b) + { + reordered[secondFctl + 8 + 26 + b] = static_cast(crc >> (24 - 8 * b)); + } + expectStill(reordered, "sequence"); + + // Truncated stream. + std::vector truncated = BuildApng(4, 4, frames); + truncated.resize(truncated.size() - 20); + expectStill(truncated, "truncated"); + + // Garbage never decodes. + const std::vector garbage(64, 0x5A); + REQUIRE(!Decode(factory.Get(), garbage).has_value()); +} + +TEST_CASE(apng_over_the_limits_falls_back_to_the_still_image) +{ + ComApartment com; + auto factory = CreateFactory(); + msimeui::AnimatedImage::Limits limits; + limits.maxFrames = 2; + const auto many = + Decode(factory.Get(), BuildApng(2, 2, {Frame(2, 2, kRed), Frame(2, 2, kGreen), Frame(2, 2, kBlue)}), limits); + REQUIRE(many.has_value() && many->FrameCount() == 1); + limits = {}; + limits.maxDecodedBytes = 4 * 4 * 4; // one 4x4 frame + const auto big = Decode(factory.Get(), BuildApng(4, 4, {Frame(4, 4, kRed), Frame(4, 4, kGreen)}), limits); + REQUIRE(big.has_value() && big->FrameCount() == 1); +} + +TEST_CASE(animation_timeline_loops_and_holds_the_last_frame_after_the_last_play) +{ + msimeui::AnimationTimeline timeline; + timeline.frameDelaysMs = {100, 200, 300}; + timeline.playCount = 2; + auto sample = timeline.SampleAt(0); + REQUIRE(sample.frameIndex == 0 && sample.nextChangeMs == 100U); + sample = timeline.SampleAt(150); + REQUIRE(sample.frameIndex == 1 && sample.nextChangeMs == 300U); + sample = timeline.SampleAt(599); + REQUIRE(sample.frameIndex == 2 && sample.nextChangeMs == 600U); + sample = timeline.SampleAt(600); + REQUIRE(sample.frameIndex == 0 && sample.nextChangeMs == 700U); + // The last frame of the last play does not change any more. + sample = timeline.SampleAt(1000); + REQUIRE(sample.frameIndex == 2 && !sample.nextChangeMs.has_value()); + sample = timeline.SampleAt(50000); + REQUIRE(sample.frameIndex == 2 && !sample.nextChangeMs.has_value()); + + timeline.playCount = 0; + sample = timeline.SampleAt(50000 * 600 + 250); + REQUIRE(sample.frameIndex == 1 && sample.nextChangeMs == 50000U * 600 + 300); + + msimeui::AnimationTimeline still; + REQUIRE(!still.SampleAt(1234).nextChangeMs.has_value()); + REQUIRE(msimeui::NormalizeFrameDelayMs(0) == 100 && msimeui::NormalizeFrameDelayMs(10) == 100 && + msimeui::NormalizeFrameDelayMs(11) == 11); +} + +TEST_CASE(apng_play_count_is_read_from_actl) +{ + ComApartment com; + auto factory = CreateFactory(); + ApngOptions options; + options.plays = 3; + const auto image = Decode(factory.Get(), BuildApng(2, 2, {Frame(2, 2, kRed), Frame(2, 2, kBlue)}, options)); + REQUIRE(image.has_value() && image->Timeline().playCount == 3); +} + +TEST_CASE(gif_frames_composite_with_disposal_transparency_and_loop_count) +{ + ComApartment com; + auto factory = CreateFactory(); + std::vector frames(4); + frames[0] = {0, 0, 4, 4, 5, 1, std::vector(16, 0)}; // red, 50 ms, keep + frames[1] = {2, 2, 2, 2, 0, 2, std::vector(4, 1)}; // blue sub-rect, 100 ms, clear afterwards + frames[2] = {0, 0, 2, 1, 20, 3, {2, 3}}; // green + transparent, 200 ms, restore + frames[3] = {1, 0, 1, 1, 1, 0, {2}}; // 10 ms shows for 100 ms + const auto image = Decode(factory.Get(), BuildGif(4, 4, frames, uint16_t{2})); + REQUIRE(image.has_value()); + REQUIRE(image->GetFormat() == msimeui::AnimatedImage::Format::Gif); + REQUIRE(image->FrameCount() == 4); + REQUIRE(image->Width() == 4 && image->Height() == 4); + REQUIRE(image->Timeline().frameDelaysMs == std::vector({50, 100, 200, 100})); + // NETSCAPE loop count 2 repeats twice after the first play. + REQUIRE(image->Timeline().playCount == 3); + REQUIRE(Near(PixelAt(*image, 1, 3, 3), kBlue)); + REQUIRE(Near(PixelAt(*image, 1, 1, 1), kRed)); + // Frame 1 was disposed to background: its rectangle is transparent in frame 2. + REQUIRE(Near(PixelAt(*image, 2, 3, 3), kClear)); + REQUIRE(Near(PixelAt(*image, 2, 0, 0), kGreen)); + // Transparent GIF pixels leave what is underneath. + REQUIRE(Near(PixelAt(*image, 2, 1, 0), kRed)); + // Frame 2 restored the canvas it started from. + REQUIRE(Near(PixelAt(*image, 3, 0, 0), kRed)); + REQUIRE(Near(PixelAt(*image, 3, 1, 0), kGreen)); + + const auto once = Decode(factory.Get(), BuildGif(4, 4, frames, std::nullopt)); + REQUIRE(once.has_value() && once->Timeline().playCount == 1); + const auto forever = Decode(factory.Get(), BuildGif(4, 4, frames, uint16_t{0})); + REQUIRE(forever.has_value() && forever->Timeline().playCount == 0); +} + +TEST_CASE(single_frame_images_decode_as_still) +{ + ComApartment com; + auto factory = CreateFactory(); + std::vector png = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}; + PutChunk(png, "IHDR", Ihdr(3, 2)); + PutChunk(png, "IDAT", RgbaScanlines(3, 2, Fill(3, 2, kBlue))); + PutChunk(png, "IEND", {}); + const auto still = Decode(factory.Get(), png); + REQUIRE(still.has_value()); + REQUIRE(still->GetFormat() == msimeui::AnimatedImage::Format::Still); + REQUIRE(!still->IsAnimated() && still->Width() == 3 && still->Height() == 2); + REQUIRE(Near(PixelAt(*still, 0, 2, 1), kBlue)); + + const TempFile stillFile(png, L".png"); + REQUIRE(!msimeui::AnimatedImage::MayBeAnimated(factory.Get(), stillFile.path)); + const TempFile apngFile(BuildApng(2, 2, {Frame(2, 2, kRed), Frame(2, 2, kBlue)}), L".apng"); + REQUIRE(msimeui::AnimatedImage::MayBeAnimated(factory.Get(), apngFile.path)); + const auto gifFrames = std::vector{{0, 0, 1, 1, 5, 0, {0}}, {0, 0, 1, 1, 5, 0, {1}}}; + const TempFile gifFile(BuildGif(1, 1, gifFrames, std::nullopt), L".gif"); + REQUIRE(msimeui::AnimatedImage::MayBeAnimated(factory.Get(), gifFile.path)); +} + +TEST_CASE(device_resources_plays_animated_images_on_the_animation_clock) +{ + ComApartment com; + HiddenWindow window; + std::vector frames = {Frame(4, 4, kRed), Frame(4, 4, kGreen), Frame(4, 4, kBlue)}; + frames[0].delayNum = 1; + frames[0].delayDen = 10; // 100 ms + frames[1].delayNum = 2; + frames[1].delayDen = 10; // 200 ms + frames[2].delayNum = 3; + frames[2].delayDen = 10; // 300 ms + ApngOptions once; + once.plays = 1; + const TempFile file(BuildApng(4, 4, frames, once), L".png"); + msimeui::DeviceResources resources; + REQUIRE(resources.EnsureForComposition(window.hwnd)); + using Clock = msimeui::DeviceResources::AnimationClock; + const Clock::time_point epoch = Clock::now(); + resources.SetAnimationEpoch(epoch); + + const auto frameAt = [&](int ms, D2D1_SIZE_F *size = nullptr) { + resources.BeginFrame(); + resources.SetFrameTime(epoch + std::chrono::milliseconds(ms)); + return resources.GetImageFrame(file.path, size); + }; + D2D1_SIZE_F size = {}; + ID2D1Bitmap *first = frameAt(0, &size); + REQUIRE(first != nullptr); + REQUIRE_NEAR(size.width, 4.0f); + REQUIRE(resources.GetNextAnimationDeadline() == epoch + std::chrono::milliseconds(100)); + REQUIRE(resources.GetAnimatedBitmapBytes() == 3 * 4 * 4 * 4); + ID2D1Bitmap *second = frameAt(150); + REQUIRE(second != nullptr && second != first); + REQUIRE(resources.GetNextAnimationDeadline() == epoch + std::chrono::milliseconds(300)); + ID2D1Bitmap *third = frameAt(310); + REQUIRE(third != nullptr && third != second && third != first); + // Played once: the last frame stays and no further frame is requested. + REQUIRE(!resources.GetNextAnimationDeadline().has_value()); + REQUIRE(frameAt(5000) == third); + REQUIRE(!resources.GetNextAnimationDeadline().has_value()); + + // Reduced motion: the still image a plain decoder shows, and no deadline. + resources.SetAnimationsEnabled(false); + ID2D1Bitmap *still = frameAt(150); + REQUIRE(still != nullptr && still == resources.GetBitmapFromFile(file.path)); + REQUIRE(!resources.GetNextAnimationDeadline().has_value()); + resources.SetAnimationsEnabled(true); + + // Clearing the caches decodes the file again on the next draw. + resources.ClearImageCaches(); + REQUIRE(resources.GetAnimatedBitmapBytes() == 0); + REQUIRE(frameAt(0) != nullptr); + REQUIRE(resources.GetAnimatedBitmapBytes() == 3 * 4 * 4 * 4); + + // Device loss drops the frames; they come back on demand. + resources.DiscardTarget(); + REQUIRE(resources.GetAnimatedBitmapBytes() == 0); + REQUIRE(resources.EnsureForComposition(window.hwnd)); + REQUIRE(frameAt(0) != nullptr); + REQUIRE(resources.GetAnimatedBitmapBytes() == 3 * 4 * 4 * 4); +} + +TEST_CASE(device_resources_shows_an_animation_over_budget_still) +{ + ComApartment com; + HiddenWindow window; + const TempFile file(BuildApng(4, 4, {Frame(4, 4, kRed), Frame(4, 4, kGreen)}), L".png"); + msimeui::DeviceResources resources; + REQUIRE(resources.EnsureForComposition(window.hwnd)); + resources.SetAnimatedBitmapBudget(4 * 4 * 4); + resources.BeginFrame(); + ID2D1Bitmap *bitmap = resources.GetImageFrame(file.path); + REQUIRE(bitmap != nullptr); + REQUIRE(!resources.GetNextAnimationDeadline().has_value()); + REQUIRE(resources.GetAnimatedBitmapBytes() == 0); + // The still path is GetBitmapFromFile's own cache. + REQUIRE(bitmap == resources.GetBitmapFromFile(file.path)); +} + +TEST_CASE(nine_slice_follows_css_border_image) +{ + ComApartment com; + HiddenWindow window; + // 9x9 image cut into 3x3 blocks: corners red, edges green, middle blue. + std::vector pixels(81); + for (uint32_t y = 0; y < 9; ++y) + { + for (uint32_t x = 0; x < 9; ++x) + { + const bool edgeX = x < 3 || x >= 6; + const bool edgeY = y < 3 || y >= 6; + pixels[y * 9 + x] = edgeX && edgeY ? kRed : (edgeX || edgeY ? kGreen : kBlue); + } + } + // Marks the inner corner of the bottom-right part, so a part drawn from the wrong place shows. + constexpr Rgba kWhite = {255, 255, 255, 255}; + pixels[6 * 9 + 6] = kWhite; + std::vector png = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A}; + PutChunk(png, "IHDR", Ihdr(9, 9)); + PutChunk(png, "IDAT", RgbaScanlines(9, 9, pixels)); + PutChunk(png, "IEND", {}); + const TempFile file(png, L".png"); + + msimeui::DeviceResources resources; + REQUIRE(resources.EnsureForComposition(window.hwnd)); + ID2D1DeviceContext *dc = resources.GetDeviceContext(); + REQUIRE(dc != nullptr); + ID2D1Bitmap *bitmap = resources.GetImageFrame(file.path); + REQUIRE(bitmap != nullptr); + + for (const float dpi : {96.0f, 120.0f, 144.0f, 192.0f}) + { + const float scale = dpi / 96.0f; + const auto format = D2D1::PixelFormat(DXGI_FORMAT_B8G8R8A8_UNORM, D2D1_ALPHA_MODE_PREMULTIPLIED); + ComPtr target; + ComPtr readback; + REQUIRE(SUCCEEDED(dc->CreateBitmap(D2D1::SizeU(100, 100), nullptr, 0, + D2D1::BitmapProperties1(D2D1_BITMAP_OPTIONS_TARGET, format, dpi, dpi), + target.GetAddressOf()))); + REQUIRE(SUCCEEDED(dc->CreateBitmap( + D2D1::SizeU(100, 100), nullptr, 0, + D2D1::BitmapProperties1(D2D1_BITMAP_OPTIONS_CPU_READ | D2D1_BITMAP_OPTIONS_CANNOT_DRAW, format, dpi, dpi), + readback.GetAddressOf()))); + const auto render = [&](const msimeui::NineSlice &nineSlice, const msimeui::RectF &box) { + ComPtr previous; + dc->GetTarget(previous.GetAddressOf()); + dc->SetTarget(target.Get()); + dc->SetDpi(dpi, dpi); + dc->BeginDraw(); + dc->Clear(D2D1::ColorF(0, 0.0f)); + msimeui::DrawNineSlice(dc, bitmap, box, nineSlice); + REQUIRE(SUCCEEDED(dc->EndDraw())); + dc->SetTarget(previous.Get()); + REQUIRE(SUCCEEDED(readback->CopyFromBitmap(nullptr, target.Get(), nullptr))); + D2D1_MAPPED_RECT mapped{}; + REQUIRE(SUCCEEDED(readback->Map(D2D1_MAP_OPTIONS_READ, &mapped))); + std::vector out(100 * 100 * 4); + for (UINT row = 0; row < 100; ++row) + { + std::copy_n(mapped.bits + static_cast(row) * mapped.pitch, 400, out.begin() + row * 400); + } + readback->Unmap(); + return out; + }; + // Device pixel at a DIP position. + const auto at = [&](const std::vector &out, float xDip, float yDip) { + const size_t x = static_cast(xDip * scale); + const size_t y = static_cast(yDip * scale); + const size_t offset = (y * 100 + x) * 4; + return Rgba{out[offset + 2], out[offset + 1], out[offset], out[offset + 3]}; + }; + + // Slices of 3 image pixels drawn 8 DIPs wide into a 40x30 DIP box. + msimeui::NineSlice nineSlice; + nineSlice.slice = {3.0f, 3.0f, 3.0f, 3.0f}; + nineSlice.width = {8.0f, 8.0f, 8.0f, 8.0f}; + nineSlice.fill = true; + auto out = render(nineSlice, {5.0f, 5.0f, 40.0f, 30.0f}); + REQUIRE(Near(at(out, 9.0f, 9.0f), kRed, 2)); // top-left corner, scaled to 8 DIPs + REQUIRE(Near(at(out, 43.5f, 33.5f), kRed, 2)); // bottom-right corner + REQUIRE(Near(at(out, 37.5f, 27.5f), kWhite, 2)); // its first source pixel lands at its top-left + REQUIRE(Near(at(out, 25.0f, 7.0f), kGreen, 2)); // top edge stretched + REQUIRE(Near(at(out, 7.0f, 20.0f), kGreen, 2)); // left edge stretched + REQUIRE(Near(at(out, 25.0f, 20.0f), kBlue, 2)); // middle + REQUIRE(Near(at(out, 2.0f, 2.0f), kClear)); // outside the box + // The last pixel row and column before a seam keep their own part's color: no bleeding. + REQUIRE(Near(at(out, 12.5f, 20.0f), kGreen, 2)); + REQUIRE(Near(at(out, 13.5f, 20.0f), kBlue, 2)); + REQUIRE(Near(at(out, 25.0f, 12.5f), kGreen, 2)); + + // fill = false leaves the middle empty, like border-image-slice without `fill`. + nineSlice.fill = false; + out = render(nineSlice, {5.0f, 5.0f, 40.0f, 30.0f}); + REQUIRE(Near(at(out, 25.0f, 20.0f), kClear)); + REQUIRE(Near(at(out, 25.0f, 7.0f), kGreen, 2)); + + // Widths that do not fit shrink together: 8 + 8 into 10 DIPs is a factor of 10/16. + nineSlice.fill = true; + out = render(nineSlice, {5.0f, 5.0f, 10.0f, 40.0f}); + REQUIRE(Near(at(out, 6.0f, 6.0f), kRed, 2)); + REQUIRE(Near(at(out, 13.5f, 6.0f), kRed, 2)); + REQUIRE(Near(at(out, 10.0f, 25.0f), kGreen, 2)); + } +}