feat: 新增 canvas 基底,D2D 候选窗支持 v2 九宫格、挂件分层与 APNG/GIF 动图 (#1014)

* feat(ui): 新增 APNG/GIF 动图解码与动画帧时钟

AnimatedImage 按 APNG 规范解析 acTL/fcTL/fdAT 并校验序号、CRC 与帧边界,逐帧重建 PNG 交给 WIC 解码后按 dispose_op/blend_op 合成;GIF 走 WIC 多帧解码。延时规则与浏览器一致,超限或损坏的动画退回静态首帧。

DeviceResources::GetImageFrame 按动画时钟取帧并汇报下一次换帧时刻,动图帧按 64 MB 预算缓存;Scene 与 Window 据此按需重绘,可整体关闭动画。DrawNineSlice 按 CSS border-image 语义区分源切片与目标边宽、支持 fill,并用夹取画刷消除接缝渗色。

* feat(server): 新增 canvas 基础皮肤并要求 v2 皮肤基于它

canvas 是全贴图基底:透明面板、无描边、无圆角、无阴影,外观来自九宫格面板图与挂件。它只能作为外部皮肤的 base,不进内置皮肤列表;v1 皮肤用它直接报错。

schema_version = 2 而 base 不是 canvas 的皮肤按 v1 加载、忽略全部 v2 字段,并在扫描结果里记一条问题。v2 字段补齐校验:图片限 .png/.apng/.gif/.webp,fill 必须是布尔,偏移与 z_index 有范围,挂件最多 32 层。

* feat(server): D2D 候选窗补齐 v2 九宫格、挂件分层与动图

canvas 皮肤在 D2D 候选窗里按 CSS border-image 语义绘制九宫格面板:切片以源图像素计、边宽以 DIP 计、fill = false 不画中心;有面板图时卡片的底色、描边、圆角与阴影让给贴图。content_insets_dip 从面板外缘量起且不小于边框宽度,line_gap_dip 只作用于预编辑行与候选之间。

挂件拆成卡片前后两层:z_index < 1 在面板之下,其余在面板之上,同层按 z_index 与声明顺序稳定排序;图片在框内等比居中。动图按场景给出的下一次换帧时刻排一次性定时器续帧,只在候选窗显示时运行,隐藏、换皮肤时停;系统关闭动画效果时只显示首帧,v1 皮肤保持静态。

设置页原生预览认得 canvas 基底并绘制面板图。

* feat(examples): 新增基于 canvas 的 v2 全贴图皮肤样例

canvas-demo 用 2 倍九宫格面板、面板下层的叶子挂件和面板上层循环播放的 APNG 星光演示 v2 皮肤,素材均为程序生成的几何图形,随目录以 MIT 授权。样例测试同步校验 schema 2 与 canvas 互为前提、v2 图片扩展名与数值范围。

* docs(examples): 说明 canvas 基底、v2 九宫格与挂件分层及动图规则

schema 说明去掉「仅 WebView2」,写明 v2 必须以 canvas 为 base、canvas 只能当 base;九宫格切片按源图像素、边宽按 DIP,content_insets_dip 不小于边宽,line_gap_dip 只管预编辑行与候选之间;挂件以 z_index 1 为界分在面板上下两层;动图格式、帧时长、上限与系统减少动画时的行为。server/docs/ui-backends.md 与 skins/README.md 同步更新。

* docs(server): 候选窗与皮肤目录的注释不再引用已删除的 WebView2 页面
This commit is contained in:
Lu Fan(SonnyCalcr) authored and GitHub committed 2026-10-17 02:57:33 +08:00
1 parent 1b875267db
commit cc868151a8
46 files changed
+3587 -262

No files matched your search

+4
View File
@@ -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
+40 -5
View File
@@ -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)。
## 测试
+42 -17
View File
@@ -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]`
Binary file not shown.

After

Width:  |  Height:  |  Size: 634 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

@@ -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."
@@ -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"
+29 -3
View File
@@ -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, {}):
+4
View File
@@ -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"
+22 -9
View File
@@ -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.
@@ -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<float>() : 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))
@@ -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/<id>/<relative> 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,
+25
View File
@@ -22,6 +22,7 @@
#include <mutex>
#include <stdexcept>
#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)}});
}
}
+138 -56
View File
@@ -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<double> &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<double>();
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<double>(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<bool> &out)
{
const toml::node *node = table.get(key);
@@ -376,6 +431,24 @@ const std::vector<std::string> &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<char>(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<Package> 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<Package> 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<double>();
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<double>();
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<bool> 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<double>();
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<Package> 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<Package> 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<int>(zValue->get());
}
dec.widthDip = BoundedNumber(*decTable, "width_dip", 2000.0);
dec.heightDip = BoundedNumber(*decTable, "height_dip", 2000.0);
dec.zIndex = static_cast<int>((*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 &notice : package->notices)
{
result.issues.push_back({folder, notice});
}
result.packages.push_back(std::move(*package));
}
else
+30 -8
View File
@@ -1,5 +1,6 @@
#pragma once
#include <cstddef>
#include <filesystem>
#include <optional>
#include <string>
@@ -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<double> sliceDip;
// 可拉伸区(上右下左,从面板外缘量起):给出时覆盖 sliceDip。
// 可拉伸区(上右下左,同为源图像素,从图片外缘量起):给出时覆盖 sliceDip。
// 面板图里画死的装饰必须落在可拉伸区之外,否则会被横向拉长。
std::vector<double> stretchDip;
// 四边宽度(DIP):top, right, bottom, left;若为空则沿用 sliceDip
// 四边在屏幕上的绘制宽度(DIP,同 CSS border-image-width);为空时取切片的数值当 DIP。
// 给 2 倍图时把它写成切片的一半,面板在 200% 缩放下仍按原图像素清晰显示。
std::vector<double> 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<std::string> notices;
std::string id;
std::string name;
std::string version;
@@ -136,7 +151,7 @@ struct Package
ToolbarColors toolbarLight;
// v2 专有特性:九宫格贴图边框
std::optional<BorderImage> borderImage;
// v2 专有特性:面板内容/文字安全内边距,从面板外缘量起(渲染时扣掉边框宽度)。
// v2 专有特性:面板内容/文字安全内边距,从面板外缘量起;某一边小于九宫格边框宽度时按边框宽度算。
std::optional<InsetsDip> contentInsetsDip;
// v2 专有特性:预编辑行与候选行之间的行距(DIP,0–100);未设置时沿用 base 的行距。
std::optional<double> lineGapDip;
@@ -159,7 +174,7 @@ struct ScanResult
std::vector<Issue> issues;
};
// 内置皮肤的设置清单:<skinsRoot>/default/<id>/skin.toml。外观仍由内置 CSS 与 D2D 配色决定,
// 内置皮肤的设置清单:<skinsRoot>/default/<id>/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<std::string> &BuiltInIds();
bool IsBuiltIn(const std::string &id);
+194 -84
View File
@@ -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 <algorithm>
#include <atomic>
#include <chrono>
#include <cmath>
#include <cctype>
#include <filesystem>
@@ -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/<skin>/*.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<CandidateSkinOverlay::Item> OverlayItems(const std::vector<CandidateSkinCatalog::FreeDecoration> &layer,
const std::wstring &packageRoot)
{
std::vector<CandidateSkinOverlay::Item> 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<float>(dec.offsetXDip);
item.offsetYDip = static_cast<float>(dec.offsetYDip);
item.widthDip = static_cast<float>(dec.widthDip);
item.heightDip = static_cast<float>(dec.heightDip);
items.push_back(std::move(item));
}
return items;
}
msimeui::Thickness ToThickness(const CandidateSkinLayers::Edges &edges)
{
return {static_cast<float>(edges.left), static_cast<float>(edges.top), static_cast<float>(edges.right),
static_cast<float>(edges.bottom)};
}
} // namespace
struct CandidatePresenter::Impl
@@ -337,8 +376,10 @@ struct CandidatePresenter::Impl
std::shared_ptr<msimeui::Image> decoration;
std::shared_ptr<msimeui::Card> card;
// 卡片与 v2 挂件层放在同一格里:挂件层测量为 0,格子仍由卡片决定,挂件按 anchor 画到卡片外沿。
// 网格按子元素顺序绘制:下层挂件、卡片、上层挂件。
std::shared_ptr<msimeui::Grid> overlayGrid;
std::shared_ptr<CandidateSkinOverlay> skinOverlay;
std::shared_ptr<CandidateSkinOverlay> skinOverlayBack;
std::shared_ptr<CandidateSkinOverlay> skinOverlayFront;
// 装饰图与卡片同列:列宽即卡片宽,装饰图在列内按 align 对齐,对应 CSS 里
// .containerParent 为 fit-content、::before 相对它定位。
std::shared_ptr<msimeui::StackPanel> 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<msimeui::StackPanel>(2.0f);
impl_->body = std::make_shared<msimeui::StackPanel>(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<msimeui::Grid>();
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<CandidateSkinOverlay>();
(*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<CandidateSkinOverlay>();
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<msimeui::StackPanel>(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<CandidateSkinCatalog::Package> 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/<skin>/;
// 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<msimeui::ShadowPass> shadowPasses = {
@@ -840,7 +909,7 @@ void CandidatePresenter::ApplySkin()
const float preeditSize = static_cast<float>((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<float>(*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<CandidateSkinOverlay::Item> 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<float>(dec.offsetXDip);
item.offsetYDip = static_cast<float>(dec.offsetYDip);
item.widthDip = static_cast<float>(dec.widthDip);
item.heightDip = static_cast<float>(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<float>(*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<float>(insets.left), static_cast<float>(insets.top),
static_cast<float>(insets.right),
static_cast<float>(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<double> &values = bi.stretchDip.empty() ? bi.sliceDip : bi.stretchDip;
const auto expand4 = [](const std::vector<double> &v, float out[4]) {
if (v.size() == 1)
out[0] = out[1] = out[2] = out[3] = static_cast<float>(v[0]);
else if (v.size() == 2)
{
out[0] = out[2] = static_cast<float>(v[0]);
out[1] = out[3] = static_cast<float>(v[1]);
}
else
{
for (size_t i = 0; i < 4; ++i)
out[i] = i < v.size() ? static_cast<float>(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<std::chrono::milliseconds>(*deadline - msimeui::DeviceResources::AnimationClock::now());
const UINT delay = static_cast<UINT>((std::clamp)(wait.count(), static_cast<long long>(USER_TIMER_MINIMUM),
static_cast<long long>(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);
+8
View File
@@ -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> 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;
@@ -0,0 +1,67 @@
#include "window/candidate_skin_layers.h"
#include <algorithm>
namespace CandidateSkinLayers
{
Edges ExpandBox(const std::vector<double> &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<double> &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<CandidateSkinCatalog::FreeDecoration> &decorations)
{
std::vector<CandidateSkinCatalog::FreeDecoration> 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
+48
View File
@@ -0,0 +1,48 @@
#pragma once
#include "skin/candidate_skin_catalog.h"
#include <vector>
// 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<double> &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<CandidateSkinCatalog::FreeDecoration> below;
// 其余,画在面板与候选文字之上。
std::vector<CandidateSkinCatalog::FreeDecoration> above;
};
// 按 z_index 升序稳定排序(同层保持声明顺序),再以 kDecorationFrontZIndex 为界分成两层。
Layers SplitDecorations(const std::vector<CandidateSkinCatalog::FreeDecoration> &decorations);
} // namespace CandidateSkinLayers
+9 -3
View File
@@ -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);
}
+3 -1
View File
@@ -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<Item> 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:
@@ -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())
@@ -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);
+2
View File
@@ -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"
@@ -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<std::string>({entry.theme}));
const bool light = std::string(entry.theme) == "light";
const auto &colors = light ? package->light : package->dark;
@@ -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"(
@@ -4,10 +4,12 @@
#include <windows.h>
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <string>
#include <system_error>
#include <vector>
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<double>({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));
}
@@ -0,0 +1,98 @@
#include "tests/includes/test_framework.h"
#include "window/candidate_skin_layers.h"
#include <string>
#include <vector>
namespace
{
CandidateSkinCatalog::FreeDecoration Decoration(const std::string &image, int zIndex)
{
CandidateSkinCatalog::FreeDecoration decoration;
decoration.image = image;
decoration.zIndex = zIndex;
return decoration;
}
std::vector<std::string> Images(const std::vector<CandidateSkinCatalog::FreeDecoration> &layer)
{
std::vector<std::string> 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<CandidateSkinCatalog::FreeDecoration> 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<std::string>({"c", "b", "e"}));
// Above: z_index >= 1, the panel itself sitting at 1.
REQUIRE_EQ(Images(layers.above), std::vector<std::string>({"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);
}
@@ -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);
}
+3 -3
View File
@@ -19,7 +19,7 @@
## 默认皮肤设置(`default/`)
[`default/`](default/) 不是外部皮肤,而是六个内置皮肤(`fluent`、`wechat`、`graphite`、`willow_green`、`autumn_osmanthus`、`microsoft`)各自的设置清单。安装包会把它们放到数据目录的 `skins\default\<id>\skin.toml`,升级时只补缺失的文件、不覆盖已有的,所以可以直接在那里改。
[`default/`](default/) 不是外部皮肤,而是六个内置皮肤(`fluent`、`wechat`、`graphite`、`willow_green`、`autumn_osmanthus`、`microsoft`)各自的设置清单;`canvas` 基底没有自己的清单,开关沿用 `fluent` 的。安装包会把它们放到数据目录的 `skins\default\<id>\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`) |
+2
View File
@@ -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
+14 -2
View File
@@ -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<msimeui::Image>(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` 决定是否画中心;各块用夹取到自身的图像画刷绘制并对齐设备像素,接缝不会互相渗色。
## 最近新增
+126
View File
@@ -0,0 +1,126 @@
#pragma once
#include <wincodec.h>
#include <cstddef>
#include <cstdint>
#include <optional>
#include <string>
#include <vector>
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<uint32_t> 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<uint64_t> 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<AnimatedImage> DecodeFile(IWICImagingFactory *factory, const std::wstring &filePath,
const Limits &limits = Limits());
static std::optional<AnimatedImage> 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<uint8_t> &FramePixels(size_t index) const
{
return frames_[index];
}
uint64_t DecodedBytes() const
{
return static_cast<uint64_t>(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<std::vector<uint8_t>> frames_;
};
} // namespace msimeui
+59 -1
View File
@@ -7,12 +7,15 @@
#include <dxgi1_2.h>
#include <dwrite.h>
#include <wincodec.h>
#include <chrono>
#include <cstdint>
#include <optional>
#include <string>
#include <unordered_map>
#include <vector>
#include <wrl/client.h>
#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<AnimationClock::time_point> 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<ID2D1Bitmap> bitmap;
};
struct AnimatedBitmapEntry
{
std::wstring filePath;
D2D1_SIZE_F size = {};
std::vector<Microsoft::WRL::ComPtr<ID2D1Bitmap>> 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<BitmapCacheEntry> bitmapCache_;
std::vector<SvgCacheEntry> svgCache_;
std::vector<RectF> 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<AnimatedBitmapEntry> animatedCache_;
std::vector<std::wstring> stillImagePaths_;
size_t animatedBytes_ = 0;
size_t animatedBudget_ = kDefaultAnimatedBitmapBudget;
bool animationsEnabled_ = true;
AnimationClock::time_point animationEpoch_ = AnimationClock::now();
std::optional<AnimationClock::time_point> frameTime_;
std::optional<AnimationClock::time_point> nextAnimationDeadline_;
};
} // namespace msimeui
+24 -9
View File
@@ -424,6 +424,24 @@ class ShadowCache
std::vector<ShadowPass> 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<ShadowPass> shadowPasses_;
+9
View File
@@ -2,8 +2,10 @@
#include "Layout.h"
#include <chrono>
#include <functional>
#include <memory>
#include <optional>
#include <vector>
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<std::chrono::steady_clock::time_point> 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<std::chrono::steady_clock::time_point> nextFrameDeadline_;
};
} // namespace msimeui
+3
View File
@@ -80,6 +80,9 @@ class Window
void SetScene(std::unique_ptr<Scene> 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();
+914
View File
@@ -0,0 +1,914 @@
// AnimatedImage: decodes still images, APNG and animated GIF into composited BGRA frames.
#include "msimeui/AnimatedImage.h"
#include <algorithm>
#include <array>
#include <cstring>
#include <shlwapi.h>
#include <utility>
#include <windows.h>
#include <wrl/client.h>
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<uint32_t>(static_cast<uint8_t>(a)) << 24) |
(static_cast<uint32_t>(static_cast<uint8_t>(b)) << 16) |
(static_cast<uint32_t>(static_cast<uint8_t>(c)) << 8) | static_cast<uint32_t>(static_cast<uint8_t>(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<uint32_t>(p[0]) << 24) | (static_cast<uint32_t>(p[1]) << 16) |
(static_cast<uint32_t>(p[2]) << 8) | static_cast<uint32_t>(p[3]);
}
uint16_t ReadBE16(const uint8_t *p)
{
return static_cast<uint16_t>((p[0] << 8) | p[1]);
}
void AppendBE32(std::vector<uint8_t> &out, uint32_t value)
{
out.push_back(static_cast<uint8_t>(value >> 24));
out.push_back(static_cast<uint8_t>(value >> 16));
out.push_back(static_cast<uint8_t>(value >> 8));
out.push_back(static_cast<uint8_t>(value));
}
const std::array<uint32_t, 256> &CrcTable()
{
static const std::array<uint32_t, 256> table = []() {
std::array<uint32_t, 256> 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<uint8_t>(type >> 24), static_cast<uint8_t>(type >> 16),
static_cast<uint8_t>(type >> 8), static_cast<uint8_t>(type)};
uint32_t crc = UpdateCrc(0xFFFFFFFFU, typeBytes, 4);
crc = UpdateCrc(crc, data, size);
return crc ^ 0xFFFFFFFFU;
}
void AppendChunk(std::vector<uint8_t> &out, uint32_t type, const uint8_t *data, size_t size)
{
AppendBE32(out, static_cast<uint32_t>(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<PngChunk> &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<size_t>(length);
if (chunk.type == kIend)
{
return true;
}
}
return false;
}
ComPtr<IWICBitmapDecoder> CreateDecoder(IWICImagingFactory *factory, const uint8_t *data, size_t size)
{
ComPtr<IWICBitmapDecoder> decoder;
if (!factory || !data || size == 0 || size > 0xFFFFFFFFULL)
{
return decoder;
}
ComPtr<IStream> stream;
stream.Attach(SHCreateMemStream(data, static_cast<UINT>(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<uint8_t> &pixels)
{
UINT w = 0;
UINT h = 0;
ComPtr<IWICFormatConverter> 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<size_t>(w) * 4;
pixels.assign(stride * h, 0);
if (FAILED(
converter->CopyPixels(nullptr, static_cast<UINT>(stride), static_cast<UINT>(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<size_t>(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<uint8_t> &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<size_t>(region.y - top + row);
const size_t srcX = static_cast<size_t>(region.x - left);
const uint8_t *src = frame.data() + (srcY * frameWidth + srcX) * 4;
uint8_t *dst = pixels_.data() + ((static_cast<size_t>(region.y) + row) * width_ + region.x) * 4;
if (!over)
{
std::memcpy(dst, src, static_cast<size_t>(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<uint8_t>((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<int64_t>(width), static_cast<int64_t>(width_));
const int64_t y1 = (std::min)(top + static_cast<int64_t>(height), static_cast<int64_t>(height_));
if (x1 <= x0 || y1 <= y0)
{
return {};
}
return {static_cast<uint32_t>(x0), static_cast<uint32_t>(y0), static_cast<uint32_t>(x1 - x0),
static_cast<uint32_t>(y1 - y0)};
}
void Clear(const Region &region)
{
for (uint32_t row = 0; row < region.height; ++row)
{
std::memset(pixels_.data() + ((static_cast<size_t>(region.y) + row) * width_ + region.x) * 4, 0,
static_cast<size_t>(region.width) * 4);
}
}
std::vector<uint8_t> Save(const Region &region) const
{
std::vector<uint8_t> saved(static_cast<size_t>(region.width) * region.height * 4);
for (uint32_t row = 0; row < region.height; ++row)
{
std::memcpy(saved.data() + static_cast<size_t>(row) * region.width * 4,
pixels_.data() + ((static_cast<size_t>(region.y) + row) * width_ + region.x) * 4,
static_cast<size_t>(region.width) * 4);
}
return saved;
}
void Restore(const Region &region, const std::vector<uint8_t> &saved)
{
for (uint32_t row = 0; row < region.height; ++row)
{
std::memcpy(pixels_.data() + ((static_cast<size_t>(region.y) + row) * width_ + region.x) * 4,
saved.data() + static_cast<size_t>(row) * region.width * 4,
static_cast<size_t>(region.width) * 4);
}
}
const std::vector<uint8_t> &Pixels() const
{
return pixels_;
}
private:
uint32_t width_;
uint32_t height_;
std::vector<uint8_t> pixels_;
};
// One composited frame of the source animation: where it lands, how it blends, what happens after.
struct FrameStep
{
std::vector<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> 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<uint8_t> &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<uint8_t> &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<uint64_t>(size.QuadPart) <= maxBytes;
if (ok)
{
bytes.resize(static_cast<size_t>(size.QuadPart));
size_t done = 0;
while (ok && done < bytes.size())
{
DWORD read = 0;
const DWORD chunk = static_cast<DWORD>((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<uint64_t>(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<PngChunk> 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<std::pair<const uint8_t *, uint32_t>> data;
};
std::vector<Frame> frames;
std::vector<PngChunk> 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<uint8_t> 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<uint8_t>(frame.width >> (24 - 8 * b));
frameIhdr[4 + b] = static_cast<uint8_t>(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<IWICBitmapDecoder> decoder = CreateDecoder(factory, png.data(), png.size());
ComPtr<IWICBitmapFrameDecode> 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<Dispose>(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<uint32_t>(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<IWICMetadataQueryReader> 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<IWICBitmapFrameDecode> 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<uint8_t> application;
std::vector<uint8_t> 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<uint32_t>(loop[2]) | (static_cast<uint32_t>(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<IWICBitmapFrameDecode> frame;
if (FAILED(decoder->GetFrame(index, frame.GetAddressOf())))
{
return false;
}
ComPtr<IWICMetadataQueryReader> 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<IWICBitmapFrameDecode> frame;
uint32_t width = 0;
uint32_t height = 0;
std::vector<uint8_t> 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<IWICBitmapFrameDecode> frame;
std::vector<uint8_t> 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> 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<IWICBitmapDecoder> 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> AnimatedImage::DecodeFile(IWICImagingFactory *factory, const std::wstring &filePath,
const Limits &limits)
{
std::vector<uint8_t> 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<uint8_t> 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<size_t>(length);
}
return false;
}
ComPtr<IWICBitmapDecoder> 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
+172
View File
@@ -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::AnimationClock::time_point> 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<AnimatedImage> 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<size_t>(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<ID2D1Bitmap> 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<float>(image->Width()), static_cast<float>(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<std::chrono::milliseconds>(now - animationEpoch_).count();
const AnimationTimeline::Sample sample =
entry->timeline.SampleAt(static_cast<uint64_t>((std::max)(elapsed, static_cast<decltype(elapsed)>(0))));
if (sample.nextChangeMs)
{
const AnimationClock::time_point deadline =
animationEpoch_ + std::chrono::milliseconds(static_cast<int64_t>(*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;
}
+26 -1
View File
@@ -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<IWICMetadataQueryReader> 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<float>(width.uiVal), static_cast<float>(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<float>(width), static_cast<float>(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)
+167 -51
View File
@@ -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<float>(pixels.width);
const float srcH = static_cast<float>(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<ID2D1DeviceContext> 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<ID2D1ImageBrush> 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),
+6
View File
@@ -360,6 +360,12 @@ void Scene::Render(DeviceResources &deviceResources)
popup.visual->Render(deviceResources);
}
}
nextFrameDeadline_ = deviceResources.GetNextAnimationDeadline();
}
std::optional<std::chrono::steady_clock::time_point> Scene::GetNextFrameDeadline() const
{
return nextFrameDeadline_;
}
Visual *Scene::FindVisualAt(const PointF &point)
+23
View File
@@ -6,6 +6,7 @@
#include "DebugLog.h"
#include <algorithm>
#include <chrono>
#include <d2d1.h>
#include <dwmapi.h>
#include <windowsx.h>
@@ -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<std::chrono::milliseconds>(*imageDeadline - DeviceResources::AnimationClock::now());
SetTimer(hwnd_, kImageAnimationTimerId, static_cast<UINT>((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
+1
View File
@@ -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
+887
View File
@@ -0,0 +1,887 @@
#include "tests/includes/test_framework.h"
#include "msimeui/AnimatedImage.h"
#include "msimeui/DeviceResources.h"
#include "msimeui/Layout.h"
#include <array>
#include <chrono>
#include <cstdint>
#include <cstdio>
#include <optional>
#include <string>
#include <vector>
#include <wincodec.h>
#include <wrl/client.h>
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<IWICImagingFactory> CreateFactory()
{
ComPtr<IWICImagingFactory> 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<uint8_t> &out, uint32_t value)
{
for (int shift = 24; shift >= 0; shift -= 8)
{
out.push_back(static_cast<uint8_t>(value >> shift));
}
}
void PutBE16(std::vector<uint8_t> &out, uint16_t value)
{
out.push_back(static_cast<uint8_t>(value >> 8));
out.push_back(static_cast<uint8_t>(value));
}
void PutLE16(std::vector<uint8_t> &out, uint16_t value)
{
out.push_back(static_cast<uint8_t>(value));
out.push_back(static_cast<uint8_t>(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<uint8_t> &out, const char *type, const std::vector<uint8_t> &data)
{
PutBE32(out, static_cast<uint32_t>(data.size()));
std::vector<uint8_t> 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<uint8_t> ZlibStored(const std::vector<uint8_t> &raw)
{
std::vector<uint8_t> 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<uint16_t>(n));
PutLE16(out, static_cast<uint16_t>(~n));
out.insert(out.end(), raw.begin() + static_cast<std::ptrdiff_t>(pos),
raw.begin() + static_cast<std::ptrdiff_t>(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<uint8_t> RgbaScanlines(uint32_t width, uint32_t height, const std::vector<Rgba> &pixels)
{
std::vector<uint8_t> 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<Rgba> Fill(uint32_t width, uint32_t height, Rgba color)
{
return std::vector<Rgba>(static_cast<size_t>(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<Rgba> pixels;
};
struct ApngOptions
{
uint32_t plays = 0;
// A still IDAT image that is not part of the animation (no fcTL before it).
std::optional<std::vector<Rgba>> separateDefaultImage;
// Leaves out the fdAT of this frame index.
std::optional<size_t> dropDataOf;
// Declared frame count; defaults to the number of frames.
std::optional<uint32_t> declaredFrames;
};
std::vector<uint8_t> Ihdr(uint32_t width, uint32_t height)
{
std::vector<uint8_t> 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<uint8_t> Fctl(uint32_t sequence, const ApngFrame &frame)
{
std::vector<uint8_t> 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<uint8_t> BuildApng(uint32_t width, uint32_t height, const std::vector<ApngFrame> &frames,
const ApngOptions &options = {})
{
std::vector<uint8_t> png = {0x89, 'P', 'N', 'G', 0x0D, 0x0A, 0x1A, 0x0A};
PutChunk(png, "IHDR", Ihdr(width, height));
std::vector<uint8_t> actl;
PutBE32(actl, options.declaredFrames.value_or(static_cast<uint32_t>(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<uint8_t> fdat;
PutBE32(fdat, sequence++);
const std::vector<uint8_t> 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<uint8_t> &png, const char *type, int nth = 0)
{
size_t pos = 8;
while (pos + 12 <= png.size())
{
const uint32_t length = (static_cast<uint32_t>(png[pos]) << 24) | (static_cast<uint32_t>(png[pos + 1]) << 16) |
(static_cast<uint32_t>(png[pos + 2]) << 8) | png[pos + 3];
if (std::equal(type, type + 4, png.begin() + static_cast<std::ptrdiff_t>(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<uint8_t> indices; // palette 0 red, 1 blue, 2 green, 3 transparent
};
std::vector<uint8_t> LzwUncompressed(const std::vector<uint8_t> &indices)
{
std::vector<uint8_t> 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<uint8_t>(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<uint8_t>(buffer));
}
return out;
}
std::vector<uint8_t> BuildGif(uint16_t width, uint16_t height, const std::vector<GifFrame> &frames,
std::optional<uint16_t> loop)
{
std::vector<uint8_t> 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<uint8_t>((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<uint8_t> 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<uint8_t>(n));
gif.insert(gif.end(), data.begin() + static_cast<std::ptrdiff_t>(pos),
data.begin() + static_cast<std::ptrdiff_t>(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<size_t>(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<int>(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<msimeui::AnimatedImage> Decode(IWICImagingFactory *factory, const std::vector<uint8_t> &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<uint8_t> &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<DWORD>(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<ApngFrame> 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<uint32_t>({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<ApngFrame> 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<ApngFrame> 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<ApngFrame> 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<ApngFrame> 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<ApngFrame> frames = {Frame(4, 4, kRed), Frame(2, 2, kBlue, 1, 1)};
const auto expectStill = [&](const std::vector<uint8_t> &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<uint8_t> 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<ApngFrame> 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<uint8_t> 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<uint8_t>(crc >> (24 - 8 * b));
}
expectStill(reordered, "sequence");
// Truncated stream.
std::vector<uint8_t> truncated = BuildApng(4, 4, frames);
truncated.resize(truncated.size() - 20);
expectStill(truncated, "truncated");
// Garbage never decodes.
const std::vector<uint8_t> 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<GifFrame> frames(4);
frames[0] = {0, 0, 4, 4, 5, 1, std::vector<uint8_t>(16, 0)}; // red, 50 ms, keep
frames[1] = {2, 2, 2, 2, 0, 2, std::vector<uint8_t>(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<uint32_t>({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<uint8_t> 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<GifFrame>{{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<ApngFrame> 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<Rgba> 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<uint8_t> 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<ID2D1Bitmap1> target;
ComPtr<ID2D1Bitmap1> 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<ID2D1Image> 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<uint8_t> out(100 * 100 * 4);
for (UINT row = 0; row < 100; ++row)
{
std::copy_n(mapped.bits + static_cast<size_t>(row) * mapped.pitch, 400, out.begin() + row * 400);
}
readback->Unmap();
return out;
};
// Device pixel at a DIP position.
const auto at = [&](const std::vector<uint8_t> &out, float xDip, float yDip) {
const size_t x = static_cast<size_t>(xDip * scale);
const size_t y = static_cast<size_t>(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));
}
}