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:
1 parent
1b875267db
commit
cc868151a8
46 files changed
+3587
-262
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)。
|
||||
|
||||
## 测试
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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, {}):
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)}});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ¬ice : package->notices)
|
||||
{
|
||||
result.issues.push_back({folder, notice});
|
||||
}
|
||||
result.packages.push_back(std::move(*package));
|
||||
}
|
||||
else
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
@@ -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`) |
|
||||
|
||||
@@ -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
@@ -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` 决定是否画中心;各块用夹取到自身的图像画刷绘制并对齐设备像素,接缝不会互相渗色。
|
||||
|
||||
## 最近新增
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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_;
|
||||
|
||||
@@ -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
|
||||
@@ -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();
|
||||
|
||||
@@ -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 ®ion)
|
||||
{
|
||||
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 ®ion) 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 ®ion, 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
|
||||
@@ -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
@@ -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
@@ -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),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user