feat(server,ui): 悬浮工具栏图片模式与装饰图叠加候选词 (#1046)

* feat(server,ui): 悬浮工具栏图片模式与装饰图叠加候选词

在 #1046 的基础上按最新 develop 重做。

悬浮工具栏 [toolbar] mode = "image":一整张背景图,按钮按
[toolbar.buttons.<按钮>] 的 x_dip / y_dip / width_dip / height_dip 摆放,
可按状态换图([images]),没图时画矢量图标。窗口按背景图的透明区域裁成
异形,透明处点得穿;图上除按钮外都能拖动。v2 皮肤的动图会播放,工具栏
隐藏或淡出后停帧。

候选窗装饰图可用 candidate_overlay_* 叠加一个候选词,太宽时缩字号;
设置页的皮肤预览一并显示。

ui 新增 OverlayPanel(绝对定位)与 ImageMask(按 alpha 生成区域)。

* fix(ui): 动图工具栏的窗口形状取所有帧的并集

窗口区域连画面一起裁,只按首帧裁形状时,动画里伸出首帧轮廓的部分
(抬头、飞出的纸)会被裁掉。改为把所有帧的 alpha 合起来再生成区域。

* fix(server): 工具栏按钮框始终算在异形窗口里

窗口形状只取背景图不透明的部分时,摆在图片透明处的按钮会被一起裁掉、
点不到。形状改为背景图轮廓并上每个按钮的框,按钮可以放在人物旁边的空白处,
不必在背景图里垫底。

* feat(server): 工具栏按钮可单独设内置图标的字号

[toolbar.buttons.<按钮>] 新增 icon_size_dip,内置 Segoe Fluent Icons 图标的
字号不再只能跟着按钮框走:按钮框铺满背景图里的圆底、图标单独调大小。
不写时仍是按钮框短边的 0.82 倍。

* feat(server): 设置页按图片模式预览悬浮工具栏

皮肤页的卡片与悬浮工具栏页的预览原来只会画矩形条。皮肤是图片模式时,
改按工具栏的画法预览:背景图(v2 皮肤的动图会播放)、按皮肤坐标摆放的
按钮、按钮贴图或内置图标(含 icon_size_dip),设置页关掉的按钮不画,
缩放与图标尺寸设置一并生效。

---------

Co-authored-by: fanlusky <1730976608@qq.com>
This commit is contained in:
一把伞南星andfanlusky authored and GitHub committed 2026-10-20 23:08:01 +08:00
1 parent b0bcb6ff5b
commit 71960b8301
25 files changed
+1900 -152

No files matched your search

+67 -1
View File
@@ -34,6 +34,18 @@
- `top_inset_dip` / `width_dip`:装饰框的高和宽,框贴在卡片上方,不与卡片重叠;图片在框内等比缩放
- `align`:`left` | `center` | `right`,相对卡片
装饰图上还可以叠加一个候选词(比如把首选词写在立绘的对话框里)。`candidate_overlay = true` 时下面这组键才生效,都可省略:
- `candidate_overlay`:`true` | `false`,默认 `false`
- `candidate_overlay_index`:叠加哪个候选,`0`–`100` 的整数。`0` 跟随高亮,`1` 是当前页第一个,依此类推,默认 `1`。当前页没有这一个时不画
- `candidate_overlay_font_size_dip`:字号,大于 `0`、不超过 `200`,默认 `16`。字体与候选相同
- `candidate_overlay_max_width_dip`:词最多占多宽,`0`–`2000`,默认 `0` 即装饰框的宽度。超出时按比例缩小字号
- `candidate_overlay_min_font_size_dip`:缩字号的下限,大于 `0`、不超过 `200`,默认 `8`。缩到下限仍放不下时就让它超出,不折行
- `candidate_overlay_color`:文字颜色,写法见[颜色格式](#颜色格式),默认白色
- `candidate_overlay_align_h`:`left` | `center` | `right`;`candidate_overlay_align_v`:`top` | `center` | `bottom`。相对装饰框对齐,默认都是 `center`
- `candidate_overlay_offset_x_dip` / `candidate_overlay_offset_y_dip`:对齐之后再平移,`-2000`–`2000`,正数向右、向下
- `candidate_overlay_strip_badge`:`true` 时只画词本身,不带候选后面的标注(如整句模型名),默认 `true`
### `[candidate_window.background]`(可选)
卡片背景图,画在底色之上、文字之下,按圆角裁剪,对深浅两套主题都生效。
@@ -48,7 +60,7 @@
- 文字与高亮用不依赖底色的中性色:深色主题近白文字(`#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` 的默认皮肤设置。
- 右键菜单没有贴图,沿用 `fluent` 的样式,可用 `[candidate.*.menu]` 改;悬浮工具栏同样沿用 `fluent`,可用 `[toolbar]` 改配色,或用[图片模式](#图片模式mode--image)整个换成贴图。翻页箭头、`fit_content` 的出厂值也取 `fluent` 的默认皮肤设置。
### `[candidate_window.border_image]`(v2 可选)
@@ -152,6 +164,60 @@ translation_separator = "#529cca73"
- `corner_radius_dip`:`0`–`32`
- `[toolbar.dark]` / `[toolbar.light]`:`background`、`border`、`handle`(左侧拖动条)、`divider`、`icon`、`hover`。`handle` 不写时取同一主题的 `[candidate.*].accent`,与选中竖条、预编辑光标同色;`accent` 也没写才沿用 `base`。写的话也请与 `accent` 一致
- `mode`:`bar`(默认,矩形条)| `image`(图片模式,见下)
### 图片模式(`mode = "image"`)
工具栏整个画成一张图,按钮按坐标摆在图上,类似搜狗的状态栏皮肤。不画底色、描边、圆角、阴影、拖动条和分隔线;**窗口按图片的透明区域裁成异形**,完全透明的地方点击会落到后面的窗口上。按钮框总是算在窗口里,所以按钮也可以摆在背景图透明的地方(比如人物旁边的空白处),不必在背景图里给它垫底。图上除按钮以外的地方都能按住拖动。
```toml
[toolbar]
mode = "image"
image = "assets/toolbar.png"
image_width_dip = 160
image_height_dip = 40
[toolbar.buttons.status]
x_dip = 10
y_dip = 7
width_dip = 26
height_dip = 26
[toolbar.buttons.status.images]
cn = "assets/cn.png"
en = "assets/en.png"
[toolbar.buttons.settings]
x_dip = 124
y_dip = 7
image = "assets/settings.png"
```
- `image`:背景图,`.png`、`.apng`、`.gif` 或 `.webp`
- `image_width_dip` / `image_height_dip`:背景图的显示尺寸,大于 `0`、不超过 `2000`。窗口就开这么大,图片拉伸到这个尺寸;给 2 倍图时写成像素的一半
- `[toolbar.buttons.<按钮>]`:每个按钮一张表,按钮名见下表,所有键都可省略
- `x_dip` / `y_dip`:按钮框左上角相对背景图左上角的位置,`0`–`2000`。**两个都写了按钮才会出现**,没写坐标的按钮不画(设置仍可从托盘菜单打开)
- `width_dip` / `height_dip`:按钮框的宽高,大于 `0`、不超过 `500`,默认 `26`
- `icon_size_dip`:没有贴图时内置图标(Segoe Fluent Icons)的字号,大于 `0`、不超过 `500`。不写时是按钮框短边的 `0.82` 倍;按钮框画在一块圆底之类的图案里、又想让图标比框大或小时单独设它
- `visible`:`false` 时把这个按钮藏起来。皮肤只能藏,用户在设置页关掉的按钮写 `true` 也不会出现
- `image`:按钮图,在按钮框里等比缩放;各个状态共用,`images` 里缺某个状态时也用它
- `[toolbar.buttons.<按钮>.images]`:状态名 → 按钮图,只有有状态的按钮能写
- 既没有 `image` 也没有对应状态的图时,画内置的矢量图标,颜色取 `[toolbar.*].icon`。鼠标悬停和按下时按钮上盖一层 `[toolbar.*].hover`
| 按钮 | 作用 | 状态名 |
|---|---|---|
| `status` | 中 / 英切换 | `cn`(中文)、`en`(英文)、`ja`(日文)、`caps`(大写锁定)、`en-candidate`(英文候选输入) |
| `fullwidth` | 全 / 半角 | `full`、`half` |
| `punctuation` | 中 / 英标点 | `cn`、`en` |
| `character_set` | 简 / 繁 | `simplified`、`traditional` |
| `emoji` | 表情面板 | 无 |
| `screen_keyboard` | 屏幕键盘 | 无 |
| `settings` | 设置 | 无 |
- 设置页「悬浮工具栏」里的缩放会同时放大背景图、按钮位置和尺寸。
- 用户在设置页关掉某个按钮后,图上它的位置就空着,背景图不会因此变短。
- 动图只在 `schema_version = 2` 的皮肤里播放,规则同[动图](#动图v2);工具栏隐藏或自动隐藏淡出后停止播放。窗口形状按所有帧合起来的轮廓裁,任何一帧画到的地方都不会被裁掉,也都能点到、能拖动。
- 未知的按钮名、状态名,以及不在 `mode = "image"` 时写的 `image` / `buttons`,不会让皮肤读不出来,只在设置页的诊断信息里提示一条。
## 颜色格式
+30 -2
View File
@@ -13,7 +13,8 @@ BUILTIN_SKINS = ("fluent", "wechat", "wechat_scroll", "graphite", "willow_green"
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).
# Images v2 tables and the toolbar's image mode may reference (border_image.source, decorations.image,
# toolbar.image, toolbar.buttons.*).
V2_IMAGE_SUFFIXES = (".png", ".apng", ".gif", ".webp")
@@ -65,11 +66,38 @@ class ManifestTests(unittest.TestCase):
self.assertTrue(-1000 <= dec.get("z_index", 2) <= 1000, "z_index")
for key in ("offset_x_dip", "offset_y_dip"):
self.assertTrue(-2000 <= dec.get(key, 0) <= 2000, key)
# Mirrors the Server catalog: the toolbar's image mode needs a background image and its size; the
# images are checked only in that mode, as the catalog ignores them otherwise.
toolbar = data.get("toolbar", {})
self.assertIn(toolbar.get("mode", "bar"), ("bar", "image"), "toolbar.mode")
if toolbar.get("mode") == "image":
assets["toolbar.image"] = toolbar["image"]
v2_images.append(toolbar["image"])
for key in ("image_width_dip", "image_height_dip"):
self.assertTrue(0 < toolbar[key] <= 2000, f"toolbar.{key}")
for name, button in toolbar.get("buttons", {}).items():
for key in ("x_dip", "y_dip"):
self.assertTrue(0 <= button.get(key, 0) <= 2000, f"toolbar.buttons.{name}.{key}")
for key in ("width_dip", "height_dip", "icon_size_dip"):
self.assertTrue(0 < button.get(key, 26) <= 500, f"toolbar.buttons.{name}.{key}")
if "image" in button:
assets[f"toolbar.buttons.{name}.image"] = button["image"]
v2_images.append(button["image"])
for state, image in button.get("images", {}).items():
assets[f"toolbar.buttons.{name}.images.{state}"] = image
v2_images.append(image)
for image in v2_images:
self.assertTrue(image.lower().endswith(V2_IMAGE_SUFFIXES), image)
if "decoration" in window:
decoration = window["decoration"]
for key in ("top_inset_dip", "width_dip"):
self.assertGreater(window["decoration"][key], 0, key)
self.assertGreater(decoration[key], 0, key)
if decoration.get("candidate_overlay", False):
for key in ("candidate_overlay_font_size_dip", "candidate_overlay_min_font_size_dip"):
self.assertTrue(0 < decoration.get(key, 8) <= 200, key)
self.assertIn(decoration.get("candidate_overlay_align_h", "center"), ("left", "center", "right"))
self.assertIn(decoration.get("candidate_overlay_align_v", "center"), ("top", "center", "bottom"))
self.assertIsInstance(decoration.get("candidate_overlay_index", 1), int)
for key, rel in assets.items():
if rel is None:
continue
@@ -190,6 +190,46 @@ ToolbarLook ResolveToolbarLook(const SettingsModel &model, const std::string &sk
t.iconRadius = skin.iconRadius;
// The toolbar's own shadow pair (8px 10px 24px, 2px 3px 8px), light α .18/.10, dark α .34/.22.
t.shadows = {{8.0f, 10.0f, 24.0f, light ? 0.18f : 0.34f}, {2.0f, 3.0f, 8.0f, light ? 0.10f : 0.22f}};
if (!package || package->toolbarMode != "image")
return t;
// The image mode, placed as FloatingToolbarPresenter::RebuildScene places it. The preview shows
// the toolbar in its usual state: Chinese input, half width, Chinese punctuation, simplified.
const std::filesystem::path directory = root / std::filesystem::u8path(package->id);
const auto path = [&](const std::string &relative) {
return (directory / std::filesystem::u8path(relative)).lexically_normal().wstring();
};
t.imageMode = true;
t.animated = package->schemaVersion >= 2;
t.image = path(package->toolbarImage);
t.imageSize = {static_cast<float>(package->toolbarImageWidthDip),
static_cast<float>(package->toolbarImageHeightDip)};
const struct
{
const char *key;
const char *state;
} kButtons[] = {{"status", "cn"}, {"fullwidth", "half"}, {"punctuation", "cn"}, {"character_set", "simplified"},
{"emoji", ""}, {"screen_keyboard", ""}, {"settings", ""}};
for (int icon = 0; icon < static_cast<int>(std::size(kButtons)); ++icon)
{
const auto found = package->toolbarButtons.find(kButtons[icon].key);
if (found == package->toolbarButtons.end())
continue;
const CandidateSkinCatalog::ToolbarButton &placement = found->second;
if (!placement.visible.value_or(true) || !placement.xDip || !placement.yDip)
continue;
ToolbarLook::ImageButton button;
button.icon = icon;
button.box = {
static_cast<float>(*placement.xDip), static_cast<float>(*placement.yDip),
static_cast<float>(placement.widthDip.value_or(CandidateSkinCatalog::kDefaultToolbarButtonSizeDip)),
static_cast<float>(placement.heightDip.value_or(CandidateSkinCatalog::kDefaultToolbarButtonSizeDip))};
const auto stateImage = placement.images.find(kButtons[icon].state);
const std::string &image = stateImage != placement.images.end() ? stateImage->second : placement.image;
if (!image.empty())
button.image = path(image);
button.iconSize = static_cast<float>(placement.iconSizeDip.value_or(0.0));
t.buttons.push_back(std::move(button));
}
return t;
}
@@ -442,6 +482,7 @@ void CandidatePreview::Set(const SettingsModel &model, const std::string &skinId
preedit.SetHeight(0.0f);
}
std::vector<msimeui::CandidateList::Item> items;
std::vector<CandidateSkinScene::PageWord> words;
const size_t count = std::min<size_t>(options.pageSize, std::size(kSampleWords));
for (size_t i = 0; i < count; ++i)
{
@@ -451,9 +492,11 @@ void CandidatePreview::Set(const SettingsModel &model, const std::string &skinId
if (options.helpcode)
item.annotation = kSampleCodes[i];
items.push_back(std::move(item));
words.push_back({kSampleWords[i], L""});
}
scene_->List()->SetItems(std::move(items));
scene_->List()->SetSelectedIndex(0);
scene_->SetDecorationCandidates(words, 0);
if (const auto &pager = scene_->Pager())
pager->SetEnabled(false, true);
InvalidateMeasure();
@@ -76,6 +76,26 @@ struct ToolbarLook
float x, y, blur, alpha;
};
std::vector<Shadow> shadows;
// A skin's image mode ([toolbar] mode = "image"): one background image with the buttons
// placed on it, as FloatingToolbarPresenter draws it. Sizes are in DIPs before scaling.
struct ImageButton
{
// Index into the toolbar icons: status, fullwidth, punctuation, character_set, emoji,
// screen_keyboard, settings.
int icon = 0;
RectF box{};
// Empty draws the built-in vector icon.
std::wstring image;
// 0 takes 0.82 of the box's shorter side.
float iconSize = 0.0f;
};
bool imageMode = false;
// Only schema_version 2 skins play their animated images.
bool animated = false;
std::wstring image;
SizeF imageSize{};
std::vector<ImageButton> buttons;
};
ToolbarLook ResolveToolbarLook(const SettingsModel &model, const std::string &skinId, bool light);
@@ -146,6 +166,9 @@ class ToolbarPreview : public msimeui::Visual
private:
float BarWidth() const;
// The image mode's own scale: the toolbar multiplies the skin's DIPs by scale * iconSize / 24.
float ImageScale() const;
void RenderImage(msimeui::DeviceResources &dr);
ToolbarLook look_;
Options options_;
@@ -89,8 +89,20 @@ float ToolbarPreview::BarWidth() const
std::max(count - 1, 0) * 6.0f * s + 4.0f * s + b;
}
float ToolbarPreview::ImageScale() const
{
return options_.scale * options_.iconSize / 24.0f;
}
SizeF ToolbarPreview::Measure(const SizeF &availableSize)
{
if (look_.imageMode)
{
const float s = ImageScale();
const float width = look_.imageSize.width * s;
return {options_.centered ? availableSize.width : std::min(width, availableSize.width),
look_.imageSize.height * s};
}
const float height = 35.0f * options_.scale;
return {options_.centered ? availableSize.width : std::min(BarWidth(), availableSize.width), height};
}
@@ -105,6 +117,11 @@ void ToolbarPreview::Render(msimeui::DeviceResources &dr)
ID2D1RenderTarget *target = dr.GetRenderTarget();
if (!target)
return;
if (look_.imageMode)
{
RenderImage(dr);
return;
}
const float s = options_.scale;
const float width = BarWidth();
const float height = 35.0f * s;
@@ -160,6 +177,63 @@ void ToolbarPreview::Render(msimeui::DeviceResources &dr)
}
}
void ToolbarPreview::RenderImage(msimeui::DeviceResources &dr)
{
ID2D1RenderTarget *target = dr.GetRenderTarget();
ComPtr<ID2D1DeviceContext> context;
target->QueryInterface(IID_PPV_ARGS(context.GetAddressOf()));
const auto draw = [&](ID2D1Bitmap *bitmap, const RectF &box) {
if (context)
context->DrawBitmap(bitmap, ToD2D(box), 1.0f, D2D1_INTERPOLATION_MODE_HIGH_QUALITY_CUBIC);
else
target->DrawBitmap(bitmap, ToD2D(box));
};
// The background stretched to its declared size, the buttons where the skin puts them; a
// button the user switched off in the settings is left out, as on the real toolbar.
const float s = ImageScale();
const float width = look_.imageSize.width * s;
const float x = options_.centered ? std::round(bounds_.x + (bounds_.width - width) * 0.5f) : bounds_.x;
const float y = bounds_.y;
if (ID2D1Bitmap *background = PreviewFrame(dr, look_.image, look_.animated))
draw(background, {x, y, width, look_.imageSize.height * s});
for (const auto &button : look_.buttons)
{
const ToolbarIcon &entry = kToolbarIcons[button.icon];
if (entry.item >= 0 && !options_.items[entry.item])
continue;
const RectF box = {x + button.box.x * s, y + button.box.y * s, button.box.width * s, button.box.height * s};
if (!button.image.empty())
{
// Fitted into the box keeping its aspect, as the toolbar's Uniform stretch does.
D2D1_SIZE_F size{};
ID2D1Bitmap *bitmap = PreviewFrame(dr, button.image, look_.animated, &size);
if (bitmap && size.width > 0.0f && size.height > 0.0f)
{
const float fit = std::min(box.width / size.width, box.height / size.height);
const SizeF fitted = {size.width * fit, size.height * fit};
draw(bitmap, {box.x + (box.width - fitted.width) * 0.5f, box.y + (box.height - fitted.height) * 0.5f,
fitted.width, fitted.height});
}
continue;
}
const float shorter = std::min(box.width, box.height);
const msimeui::IconGlyph glyph = msimeui::ResolveIconGlyph(entry.codepoint);
TextStyle style;
style.lineHeight = 1.0f;
style.size = button.iconSize > 0.0f ? button.iconSize * s : shorter * 0.82f;
if (glyph.family)
{
style.family = glyph.family;
DrawCentered(dr, std::wstring(1, glyph.codepoint), style, box, look_.icon);
}
else
{
DrawCentered(dr, entry.fallback, style, box, look_.icon);
}
}
}
// ---------------------------------------------------------------------------------
// CaretStatePreview
// ---------------------------------------------------------------------------------
+297
View File
@@ -428,6 +428,243 @@ bool ReadToolbarColors(const toml::node *node, ToolbarColors &out)
ReadCssColor(*table, "icon", out.icon) && ReadCssColor(*table, "hover", out.hover);
}
// [candidate_window.decoration] 里的 candidate_overlay_* 一组键;candidate_overlay 不是 true 时其余键不读。
bool ReadDecorationCandidate(const toml::table &decoration, Package &package, std::string *error)
{
std::optional<bool> enabled;
if (!ReadOptionalBool(decoration, "candidate_overlay", enabled))
{
SetError(error, "candidate_window.decoration.candidate_overlay 必须是 true 或 false");
return false;
}
if (!enabled.value_or(false))
{
return true;
}
DecorationCandidate overlay;
std::optional<double> fontSize;
std::optional<double> minFontSize;
std::optional<double> maxWidth;
if (!ReadOptionalBounded(decoration, "candidate_overlay_font_size_dip", 200.0, fontSize) ||
!ReadOptionalBounded(decoration, "candidate_overlay_min_font_size_dip", 200.0, minFontSize) ||
(fontSize && *fontSize <= 0.0) || (minFontSize && *minFontSize <= 0.0))
{
SetError(error, "candidate_window.decoration 的 candidate_overlay_font_size_dip 与 "
"candidate_overlay_min_font_size_dip 必须是大于 0、不超过 200 的数值");
return false;
}
if (!ReadOptionalBounded(decoration, "candidate_overlay_max_width_dip", 2000.0, maxWidth))
{
SetError(error, "candidate_window.decoration.candidate_overlay_max_width_dip 必须是 0–2000 的数值");
return false;
}
if (!ReadCssColor(decoration, "candidate_overlay_color", overlay.color))
{
SetError(error, "candidate_window.decoration.candidate_overlay_color 无效");
return false;
}
if (!ReadEnum(decoration, "candidate_overlay_align_h", {"left", "center", "right"}, overlay.alignH) ||
!ReadEnum(decoration, "candidate_overlay_align_v", {"top", "center", "bottom"}, overlay.alignV))
{
SetError(error, "candidate_window.decoration 的 candidate_overlay_align_h 只能是 left、center 或 right,"
"candidate_overlay_align_v 只能是 top、center 或 bottom");
return false;
}
if (!ReadSignedNumber(decoration, "candidate_overlay_offset_x_dip", kDecorationOffsetLimitDip,
overlay.offsetXDip) ||
!ReadSignedNumber(decoration, "candidate_overlay_offset_y_dip", kDecorationOffsetLimitDip, overlay.offsetYDip))
{
SetError(error,
"candidate_window.decoration 的 candidate_overlay_offset_x_dip / candidate_overlay_offset_y_dip "
"必须在 -2000–2000 之间");
return false;
}
std::optional<bool> stripBadge;
if (!ReadOptionalBool(decoration, "candidate_overlay_strip_badge", stripBadge))
{
SetError(error, "candidate_window.decoration.candidate_overlay_strip_badge 必须是 true 或 false");
return false;
}
if (const toml::node *indexNode = decoration.get("candidate_overlay_index"))
{
const auto *index = indexNode->as_integer();
if (!index || index->get() < 0 || index->get() > 100)
{
SetError(error, "candidate_window.decoration.candidate_overlay_index 必须是 0–100 的整数");
return false;
}
overlay.index = static_cast<int>(index->get());
}
overlay.fontSizeDip = fontSize.value_or(overlay.fontSizeDip);
overlay.minFontSizeDip = minFontSize.value_or(overlay.minFontSizeDip);
overlay.maxWidthDip = maxWidth.value_or(overlay.maxWidthDip);
overlay.stripBadge = stripBadge.value_or(true);
package.decorationCandidate = overlay;
return true;
}
bool IsToolbarImagePath(const std::string &path)
{
return IsSafeRelativeResource(path) && IsAnimatableImagePath(path);
}
// [toolbar.buttons.<key>] 的一项。未知的状态名记一条提示后忽略,不让整份皮肤读不出来。
bool ReadToolbarButton(const toml::node &node, const std::string &key, const std::vector<std::string> &states,
Package &package, std::string *error)
{
const std::string where = "toolbar.buttons." + key;
const auto *table = node.as_table();
if (!table)
{
SetError(error, where + " 必须是表");
return false;
}
ToolbarButton button;
if (!ReadOptionalBool(*table, "visible", button.visible))
{
SetError(error, where + ".visible 必须是 true 或 false");
return false;
}
if (!ReadOptionalBounded(*table, "x_dip", 2000.0, button.xDip) ||
!ReadOptionalBounded(*table, "y_dip", 2000.0, button.yDip))
{
SetError(error, where + " 的 x_dip / y_dip 必须是 0–2000 的数值");
return false;
}
if (!ReadOptionalBounded(*table, "width_dip", 500.0, button.widthDip) ||
!ReadOptionalBounded(*table, "height_dip", 500.0, button.heightDip) ||
(button.widthDip && *button.widthDip <= 0.0) || (button.heightDip && *button.heightDip <= 0.0))
{
SetError(error, where + " 的 width_dip / height_dip 必须是大于 0、不超过 500 的数值");
return false;
}
if (!ReadOptionalBounded(*table, "icon_size_dip", 500.0, button.iconSizeDip) ||
(button.iconSizeDip && *button.iconSizeDip <= 0.0))
{
SetError(error, where + ".icon_size_dip 必须是大于 0、不超过 500 的数值");
return false;
}
if (table->contains("image") &&
(!ReadString(*table, "image", button.image, 256, true) || !IsToolbarImagePath(button.image)))
{
SetError(error, where + ".image 无效(需为皮肤目录里的 .png、.apng、.gif 或 .webp)");
return false;
}
if (const toml::node *imagesNode = table->get("images"))
{
const auto *images = imagesNode->as_table();
if (!images)
{
SetError(error, where + ".images 必须是表");
return false;
}
if (states.empty())
{
SetError(error, where + " 没有状态之分,只能写 image,不能写 images");
return false;
}
for (const auto &[stateKey, pathNode] : *images)
{
const std::string state(stateKey.str());
const auto *path = pathNode.as_string();
if (!path || !IsToolbarImagePath(path->get()))
{
SetError(error, where + ".images." + state + " 无效(需为皮肤目录里的 .png、.apng、.gif 或 .webp)");
return false;
}
if (std::find(states.begin(), states.end(), state) == states.end())
{
package.notices.push_back(where + ".images 里的 " + state + " 不是这个按钮的状态,已忽略");
continue;
}
button.images[state] = path->get();
}
}
if (button.xDip.has_value() != button.yDip.has_value())
{
package.notices.push_back(where + " 只写了 x_dip、y_dip 其中一个,这个按钮不会显示");
}
package.toolbarButtons[key] = std::move(button);
return true;
}
// [toolbar] 的图片模式:mode、image、image_width_dip、image_height_dip 与 [toolbar.buttons.<key>]。
bool ReadToolbarImageMode(const toml::table &toolbar, Package &package, std::string *error)
{
if (!ReadEnum(toolbar, "mode", {"bar", "image"}, package.toolbarMode))
{
SetError(error, "toolbar.mode 只能是 bar 或 image");
return false;
}
if (toolbar.contains("image") &&
(!ReadString(toolbar, "image", package.toolbarImage, 256, true) || !IsToolbarImagePath(package.toolbarImage)))
{
SetError(error, "toolbar.image 无效(需为皮肤目录里的 .png、.apng、.gif 或 .webp)");
return false;
}
std::optional<double> width;
std::optional<double> height;
if (!ReadOptionalBounded(toolbar, "image_width_dip", 2000.0, width) ||
!ReadOptionalBounded(toolbar, "image_height_dip", 2000.0, height))
{
SetError(error, "toolbar.image_width_dip / image_height_dip 必须是 0–2000 的数值");
return false;
}
package.toolbarImageWidthDip = width.value_or(0.0);
package.toolbarImageHeightDip = height.value_or(0.0);
if (const toml::node *buttonsNode = toolbar.get("buttons"))
{
const auto *buttons = buttonsNode->as_table();
if (!buttons)
{
SetError(error, "toolbar.buttons 必须是表");
return false;
}
for (const auto &[keyNode, value] : *buttons)
{
const std::string key(keyNode.str());
const std::vector<std::string> *states = ToolbarButtonStates(key);
if (!states)
{
package.notices.push_back("没有 toolbar.buttons." + key + " 这个按钮,已忽略");
continue;
}
if (!ReadToolbarButton(value, key, *states, package, error))
{
return false;
}
}
}
if (package.toolbarMode != "image")
{
if (!package.toolbarImage.empty() || !package.toolbarButtons.empty())
{
package.notices.push_back("toolbar 的 image 与 buttons 只在 mode = \"image\" 时生效,已忽略");
}
return true;
}
// 渲染端按这两个尺寸开窗口,不去读图片的像素大小。
if (package.toolbarImage.empty() || package.toolbarImageWidthDip <= 0.0 || package.toolbarImageHeightDip <= 0.0)
{
SetError(error, "toolbar.mode = \"image\" 需要同时写 image、image_width_dip 与 image_height_dip");
return false;
}
for (const auto &[key, button] : package.toolbarButtons)
{
if (!button.xDip || !button.yDip)
{
continue;
}
const double right = *button.xDip + button.widthDip.value_or(kDefaultToolbarButtonSizeDip);
const double bottom = *button.yDip + button.heightDip.value_or(kDefaultToolbarButtonSizeDip);
if (right > package.toolbarImageWidthDip || bottom > package.toolbarImageHeightDip)
{
package.notices.push_back("toolbar.buttons." + key + " 超出了背景图,超出的部分既画不出来也点不到");
}
}
return true;
}
// 长度:0–maximum 的 DIP 数值,或 "<数值>em"(0–maximumEm 倍字号)。键不存在时什么也不做。
bool ReadLength(const toml::table &table, const char *key, double maximum, double maximumEm, std::optional<Length> &out)
{
@@ -781,6 +1018,26 @@ bool IsAnimatableImagePath(const std::string &path)
return extension == ".png" || extension == ".apng" || extension == ".gif" || extension == ".webp";
}
const std::vector<std::string> *ToolbarButtonStates(const std::string &key)
{
static const std::vector<std::string> status = {"cn", "en", "ja", "caps", "en-candidate"};
static const std::vector<std::string> fullwidth = {"full", "half"};
static const std::vector<std::string> punctuation = {"cn", "en"};
static const std::vector<std::string> characterSet = {"simplified", "traditional"};
static const std::vector<std::string> stateless;
if (key == "status")
return &status;
if (key == "fullwidth")
return &fullwidth;
if (key == "punctuation")
return &punctuation;
if (key == "character_set")
return &characterSet;
if (key == "emoji" || key == "screen_keyboard" || key == "settings")
return &stateless;
return nullptr;
}
bool IsBuiltIn(const std::string &id)
{
const auto &ids = BuiltInIds();
@@ -913,6 +1170,10 @@ std::optional<Package> Load(const std::filesystem::path &skinsRoot, const std::s
SetError(error, "candidate_window.decoration 尺寸无效");
return std::nullopt;
}
if (!ReadDecorationCandidate(*decoration, package, error))
{
return std::nullopt;
}
}
if (!ReadEnum(*window, "backdrop", {"none", "acrylic", "mica"}, package.backdrop))
{
@@ -1120,6 +1381,14 @@ std::optional<Package> Load(const std::filesystem::path &skinsRoot, const std::s
{
return std::nullopt;
}
// 工具栏图片模式的贴图都在皮肤目录里,所以只有外部皮肤能用,不放进内置清单共用的 ReadLook。
if (const auto *toolbar = root["toolbar"].as_table())
{
if (!ReadToolbarImageMode(*toolbar, package, error))
{
return std::nullopt;
}
}
std::error_code ec;
if (!package.decorationImage.empty() &&
!std::filesystem::is_regular_file(directory / std::filesystem::u8path(package.decorationImage), ec))
@@ -1155,6 +1424,34 @@ std::optional<Package> Load(const std::filesystem::path &skinsRoot, const std::s
return std::nullopt;
}
}
// bar 模式用不到这些图,不去检查。
if (package.toolbarMode == "image")
{
const auto exists = [&](const std::string &path) {
return std::filesystem::is_regular_file(directory / std::filesystem::u8path(path), ec);
};
if (!exists(package.toolbarImage))
{
SetError(error, "找不到 toolbar.image 文件");
return std::nullopt;
}
for (const auto &[key, button] : package.toolbarButtons)
{
if (!button.image.empty() && !exists(button.image))
{
SetError(error, "找不到 toolbar.buttons." + key + ".image 文件");
return std::nullopt;
}
for (const auto &[state, path] : button.images)
{
if (!exists(path))
{
SetError(error, "找不到 toolbar.buttons." + key + ".images." + state + " 文件");
return std::nullopt;
}
}
}
}
AdaptSharedBase(package);
// 工具栏拖动条没单独写时跟候选的 accent 走,与选中竖条、预编辑光标同色;accent 也没写才沿用 base。
FillColor(package.toolbarDark.handle, package.dark.accent);
+59
View File
@@ -2,6 +2,7 @@
#include <cstddef>
#include <filesystem>
#include <map>
#include <optional>
#include <string>
#include <vector>
@@ -65,6 +66,47 @@ struct ToolbarColors
std::string hover;
};
// 图片模式([toolbar] mode = "image")下一个按钮的摆法与贴图:[toolbar.buttons.<key>]。
struct ToolbarButton
{
// false 时皮肤把它藏起来;皮肤不能让用户在设置页关掉的按钮重新出现。
std::optional<bool> visible;
// 按钮框左上角相对背景图左上角的位置(DIP)。两个都写了按钮才会出现,没写坐标的按钮不画。
std::optional<double> xDip;
std::optional<double> yDip;
// 按钮框的宽高(DIP);没写时为 kDefaultToolbarButtonSizeDip。
std::optional<double> widthDip;
std::optional<double> heightDip;
// 没有贴图时画的内置图标(Segoe Fluent Icons)的字号(DIP);没写时为按钮框短边的 0.82 倍。
std::optional<double> iconSizeDip;
// 各状态共用的图;images 里没有当前状态的图时也用它。两者都没有就画内置的矢量图标。
std::string image;
// 状态名 → 图,只对有状态的按钮有效,状态名见 ToolbarButtonStates。
std::map<std::string, std::string> images;
};
inline constexpr double kDefaultToolbarButtonSizeDip = 26.0;
// 装饰图上叠加一个候选词([candidate_window.decoration] 的 candidate_overlay_* 一组键)。
struct DecorationCandidate
{
// 叠加哪一个候选:0 跟随高亮,1 起为当前页的第几个。
int index = 1;
double fontSizeDip = 16.0;
// 词太宽时字号往下缩,最小缩到这么大,再宽就让它超出。
double minFontSizeDip = 8.0;
// 词最多占多宽(DIP);0 为装饰框的宽度。
double maxWidthDip = 0.0;
// CSS 颜色;为空是白色。
std::string color;
std::string alignH = "center"; // left / center / right,相对装饰框
std::string alignV = "center"; // top / center / bottom,相对装饰框
double offsetXDip = 0.0;
double offsetYDip = 0.0;
// 只画词本身,不带候选后面的标注(如整句模型名)。
bool stripBadge = true;
};
struct BorderImage
{
std::string source;
@@ -195,6 +237,8 @@ struct Package
// 卡片上方的装饰图;为空表示皮肤没有装饰。
std::string decorationImage;
std::string decorationAlign = "right"; // left / center / right,相对卡片
// 叠加在装饰图上的候选词;没开 candidate_overlay 时为空。
std::optional<DecorationCandidate> decorationCandidate;
// 候选框里的翻页箭头;未设置时沿用 base 默认皮肤的设置(见 ResolvePageArrows)。
std::optional<bool> pageArrows;
// 卡片严格按候选文字所需的宽度收紧,不再垫出厂最小宽度、也不在同一输入里只增不减;
@@ -216,6 +260,15 @@ struct Package
std::optional<double> toolbarIconCornerRadiusDip;
ToolbarColors toolbarDark;
ToolbarColors toolbarLight;
// 悬浮工具栏的画法:bar(缺省)是矩形条;image 是一整张背景图,按钮按 toolbarButtons 的坐标摆在图上,
// 不画底色、描边、阴影、把手与分隔线,窗口按图片的透明区域裁成异形,透明处点得穿。
std::string toolbarMode = "bar";
// image 模式的背景图(相对皮肤目录)与它的显示尺寸(DIP)。
std::string toolbarImage;
double toolbarImageWidthDip = 0.0;
double toolbarImageHeightDip = 0.0;
// 按钮键 → 摆法与贴图,只在 image 模式生效。键见 ToolbarButtonStates。
std::map<std::string, ToolbarButton> toolbarButtons;
// v2 专有特性:九宫格贴图边框
std::optional<BorderImage> borderImage;
// v2 专有特性:面板内容/文字安全内边距,从面板外缘量起;某一边小于九宫格边框宽度时按边框宽度算。
@@ -275,6 +328,12 @@ bool IsCanvasBase(const std::string &base);
// v2 图片(border_image.source、decorations.image)允许的扩展名:.png、.apng、.gif、.webp,不分大小写。
bool IsAnimatableImagePath(const std::string &path);
// 悬浮工具栏按钮键(status、fullwidth、punctuation、character_set、emoji、screen_keyboard、settings)的
// 状态名,即 images 表的键;无状态的按钮是空表。不是按钮键时返回 nullptr。
// status: cn / en / ja / caps / en-candidate;fullwidth: full / half;punctuation: cn / en;
// character_set: simplified / traditional
const std::vector<std::string> *ToolbarButtonStates(const std::string &key);
// 指定主题下实际要画的面板图:浅色主题优先 light_source,没写就用 source。
const std::string &BorderImageSource(const BorderImage &borderImage, bool light);
+27 -7
View File
@@ -176,7 +176,7 @@ bool KeepRectOnVisibleScreens(RECT &rect, ScreenArea area, HMONITOR *monitor, co
return true;
}
void ClipWindowToVisibleScreens(HWND hwnd, ScreenArea area)
void ClipWindowToVisibleScreens(HWND hwnd, ScreenArea area, HRGN shape)
{
RECT rect{};
if (!hwnd || !GetWindowRect(hwnd, &rect))
@@ -195,18 +195,28 @@ void ClipWindowToVisibleScreens(HWND hwnd, ScreenArea area)
const bool hasRegion = GetWindowRgn(hwnd, current) != ERROR;
// Nothing visible means the host is mid-drag between screens; the
// clamp at the end of the drag brings it back, so leave it alone.
if (kind == NULLREGION || kind == ERROR || EqualRgn(clipped, whole))
if (kind != NULLREGION)
{
if (hasRegion && kind != NULLREGION)
const bool whollyVisible = kind == ERROR || EqualRgn(clipped, whole);
OffsetRgn(clipped, -rect.left, -rect.top);
// A shaped window keeps its shape, trimmed to the visible part.
if (shape && whollyVisible)
{
CombineRgn(clipped, shape, nullptr, RGN_COPY);
}
else if (shape)
{
CombineRgn(clipped, clipped, shape, RGN_AND);
}
if (whollyVisible && !shape)
{
if (hasRegion)
{
SetWindowRgn(hwnd, nullptr, TRUE);
}
}
else
{
OffsetRgn(clipped, -rect.left, -rect.top);
// SetWindowRgn repaints, and it runs on every move during a drag.
if (!hasRegion || !EqualRgn(current, clipped))
else if (!hasRegion || !EqualRgn(current, clipped))
{
// The system owns the region once it is set.
if (SetWindowRgn(hwnd, clipped, TRUE))
@@ -225,6 +235,16 @@ void ClipWindowToVisibleScreens(HWND hwnd, ScreenArea area)
}
}
bool SystemAnimationsEnabled()
{
BOOL enabled = TRUE;
if (!SystemParametersInfoW(SPI_GETCLIENTAREAANIMATION, 0, &enabled, 0))
{
return true;
}
return enabled != FALSE;
}
bool IsWindowInMoveSizeLoop(HWND hwnd)
{
// Ask USER32 instead of mirroring WM_ENTERSIZEMOVE/WM_EXITSIZEMOVE in a
+9 -1
View File
@@ -88,7 +88,15 @@ bool KeepRectOnVisibleScreens(RECT &rect, ScreenArea area, HMONITOR *monitor = n
// Clips `hwnd` to the part of it inside the visible region, so margin hanging
// off an edge or over the taskbar is neither drawn nor hit-tested there.
// Clears the region again once the whole window is visible.
void ClipWindowToVisibleScreens(HWND hwnd, ScreenArea area);
// `shape` (window coordinates, still owned by the caller) is the window's own
// shape, such as the opaque part of a toolbar image: the region becomes its
// visible part, and stays the shape once the whole window is visible.
void ClipWindowToVisibleScreens(HWND hwnd, ScreenArea area, HRGN shape = nullptr);
// The system "Animation effects" switch (Settings > Accessibility > Visual effects), the
// one browsers expose as prefers-reduced-motion. Off: animated images show their first
// frame. Reads as on when the setting cannot be read.
bool SystemAnimationsEnabled();
// True while `hwnd` is inside a native move/size loop (caption drag).
bool IsWindowInMoveSizeLoop(HWND hwnd);
// Top-left for a host about to be resized to `width` x `height` physical px.
+5 -13
View File
@@ -113,18 +113,6 @@ float StickyCardExtent(float kept, float natural)
// 候选窗 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;
}
// 系统「透明效果」(设置 → 个性化 → 颜色)。关掉时系统材质一律退回纯色,皮肤的亚克力也跟着退回不透明底色。
// 读不到时按开启处理。
bool SystemTransparencyEnabled()
@@ -354,12 +342,15 @@ std::uint64_t CandidatePresenter::FillItemsFromUi()
const bool translationsEnabled = GetConfiguredCandidateTranslationsEnabled();
std::vector<msimeui::CandidateList::Item> items;
items.reserve(page->page_views.size());
std::vector<CandidateSkinScene::PageWord> words;
words.reserve(page->page_views.size());
for (size_t i = 0; i < page->page_views.size(); ++i)
{
const auto &view = page->page_views[i];
words.push_back({string_to_wstring(view.text), string_to_wstring(view.badge)});
msimeui::CandidateList::Item item;
item.label = std::to_wstring(i + 1);
item.text = string_to_wstring(view.text + view.badge);
item.text = words.back().text + words.back().badge;
item.annotation = string_to_wstring(view.annotation);
if (translationsEnabled)
{
@@ -384,6 +375,7 @@ std::uint64_t CandidatePresenter::FillItemsFromUi()
impl_->list->SetItems(std::move(items));
impl_->list->SetSelectedIndex(static_cast<size_t>((std::max)(0, page->selected_index_in_page)));
ignoreSelectionCallback_ = false;
impl_->scene.SetDecorationCandidates(words, static_cast<size_t>((std::max)(0, page->selected_index_in_page)));
if (impl_->pager)
{
impl_->pager->SetEnabled(page->has_previous_page, page->has_next_page);
+80 -5
View File
@@ -412,8 +412,14 @@ void CandidateSkinScene::Build(bool horizontal, bool pageArrows, bool expandPane
expandPanel_ = expandPanel;
root_ = std::make_shared<msimeui::StackPanel>(0.0f);
decoration_ = std::make_shared<msimeui::Image>(L"");
decoration_->SetHorizontalAlignment(msimeui::HorizontalAlignment::Trailing);
decoration_->SetStretch(msimeui::ImageStretch::Uniform);
decorationWord_ = std::make_shared<msimeui::TextBlock>(L"", 16.0f, D2D1::ColorF(D2D1::ColorF::White));
decorationWord_->SetTextLayoutPadding({0.0f, 0.0f, 0.0f, 0.0f});
decorationBox_ = std::make_shared<msimeui::OverlayPanel>();
decorationBox_->SetHorizontalAlignment(msimeui::HorizontalAlignment::Trailing);
decorationBox_->AddChildAt(decoration_, 0.0f, 0.0f);
decorationBox_->AddChildAt(decorationWord_, 0.0f, 0.0f);
decorationWordShown_.clear();
preedit_ = std::make_shared<msimeui::TextBlock>(L"", 14.0f, D2D1::ColorF(0xF5F5F5));
preedit_->SetTextLayoutPadding({0.0f, 1.0f, 0.0f, 1.0f});
preedit_->SetHorizontalAlignment(msimeui::HorizontalAlignment::Leading);
@@ -503,7 +509,7 @@ void CandidateSkinScene::Build(bool horizontal, bool pageArrows, bool expandPane
cardColumn_ = std::make_shared<msimeui::StackPanel>(0.0f);
cardColumn_->SetHorizontalAlignment(msimeui::HorizontalAlignment::Leading);
cardColumn_->SetHorizontalContentAlignment(msimeui::HorizontalAlignment::Stretch);
cardColumn_->AddChild(decoration_);
cardColumn_->AddChild(decorationBox_);
cardColumn_->AddChild(overlayGrid_);
frame_ = std::make_shared<msimeui::Container>();
frame_->SetChild(cardColumn_);
@@ -815,19 +821,38 @@ CandidateSkinScene::Result CandidateSkinScene::Apply(const Settings &settings)
result.cardMinWidthDip = cardMinWidthDip;
// 外框上方留给阴影的空白与装饰盒重叠,装饰盒高过它时不再额外留白。
msimeui::Thickness framePad = settings.framePadding;
decorationCandidate_.reset();
decorationWordShown_.clear();
decorationWord_->SetText(L"");
if (decorationPath.empty() || decorationTopDip <= 0.0f)
{
decorationBoxWidthDip_ = 0.0f;
decorationBoxHeightDip_ = 0.0f;
decorationBox_->SetHeight(0.0f);
decorationBox_->SetWidth(0.0f);
decoration_->SetHeight(0.0f);
decoration_->ClearWidth();
decoration_->SetSource(L"");
}
else
{
decorationBoxWidthDip_ = static_cast<float>(package->decorationWidthDip);
decorationBoxHeightDip_ = decorationTopDip;
decoration_->SetSource(decorationPath);
decoration_->SetHeight(decorationTopDip);
decoration_->SetWidth(static_cast<float>(package->decorationWidthDip));
decoration_->SetHorizontalAlignment(DecorationAlignment(package->decorationAlign));
decoration_->SetHeight(decorationBoxHeightDip_);
decoration_->SetWidth(decorationBoxWidthDip_);
decorationBox_->SetHeight(decorationBoxHeightDip_);
decorationBox_->SetWidth(decorationBoxWidthDip_);
decorationBox_->SetHorizontalAlignment(DecorationAlignment(package->decorationAlign));
framePad.top = (std::max)(0.0f, framePad.top - decorationTopDip);
decorationCandidate_ = package->decorationCandidate;
if (decorationCandidate_)
{
decorationWord_->SetFontFamily(appearance.fontFamily);
decorationWord_->SetFallbackFontFamilies(appearance.fallbackFontFamilies);
decorationWord_->SetColor(
ParseCandidateCssColor(decorationCandidate_->color, D2D1::ColorF(D2D1::ColorF::White)));
}
}
// v2 挂件挂在卡片外沿,要按 outer_insets_dip 往四边多留出空白,否则立绘被裁掉。
if (package && package->outerInsetsDip)
@@ -899,3 +924,53 @@ CandidateSkinScene::Result CandidateSkinScene::Apply(const Settings &settings)
result.palette = palette;
return result;
}
void CandidateSkinScene::SetDecorationCandidates(const std::vector<PageWord> &page, size_t selectedIndex)
{
if (!decorationWord_)
return;
std::wstring word;
if (decorationCandidate_)
{
const size_t index =
decorationCandidate_->index == 0 ? selectedIndex : static_cast<size_t>(decorationCandidate_->index - 1);
if (index < page.size())
word = decorationCandidate_->stripBadge ? page[index].text : page[index].text + page[index].badge;
}
if (word == decorationWordShown_)
return;
decorationWordShown_ = word;
decorationWord_->SetText(word);
if (word.empty())
return;
const CandidateSkinCatalog::DecorationCandidate &style = *decorationCandidate_;
// 先按皮肤字号量出一行的宽度,宽过上限就按比例缩小字号,最小到 min_font_size_dip。
const msimeui::SizeF unbounded = {10000.0f, 10000.0f};
float fontSize = static_cast<float>(style.fontSizeDip);
decorationWord_->ClearWidth();
decorationWord_->ClearHeight();
decorationWord_->SetFontSize(fontSize);
msimeui::SizeF size = decorationWord_->MeasureInLayout(unbounded);
const float limit = style.maxWidthDip > 0.0 ? static_cast<float>(style.maxWidthDip) : decorationBoxWidthDip_;
const float minFontSize = (std::min)(fontSize, static_cast<float>(style.minFontSizeDip));
if (size.width > limit && size.width > 0.0f && fontSize > minFontSize)
{
fontSize = (std::max)(minFontSize, fontSize * limit / size.width);
decorationWord_->SetFontSize(fontSize);
size = decorationWord_->MeasureInLayout(unbounded);
}
// 定死量出来的尺寸:装饰框比词窄时词照样画成一行(超出框的部分照画),不在框宽处折行。
decorationWord_->SetWidth(std::ceil(size.width));
decorationWord_->SetHeight(std::ceil(size.height));
float x = static_cast<float>(style.offsetXDip);
if (style.alignH == "center")
x += (decorationBoxWidthDip_ - size.width) * 0.5f;
else if (style.alignH == "right")
x += decorationBoxWidthDip_ - size.width;
float y = static_cast<float>(style.offsetYDip);
if (style.alignV == "center")
y += (decorationBoxHeightDip_ - size.height) * 0.5f;
else if (style.alignV == "bottom")
y += decorationBoxHeightDip_ - size.height;
decorationBox_->SetChildPosition(decorationWord_.get(), x, y);
}
+19
View File
@@ -176,9 +176,19 @@ class CandidateSkinScene
float expandRowStepDip = 30.0f;
};
// 当前页的一个候选:词本身与后面的标注(如整句模型名)。
struct PageWord
{
std::wstring text;
std::wstring badge;
};
explicit CandidateSkinScene(Callbacks callbacks = {});
Result Apply(const Settings &settings);
// 皮肤开了 candidate_overlay 时,按它指定的第几个候选从当前页取词画到装饰图上,太宽就缩字号;
// 其余皮肤什么也不画。selectedIndex 是当前页高亮的候选。
void SetDecorationCandidates(const std::vector<PageWord> &page, size_t selectedIndex);
// 场景的根(外框所在的竖排面板);第一次 Apply 之前为空。
const std::shared_ptr<msimeui::StackPanel> &Root() const
@@ -231,7 +241,16 @@ class CandidateSkinScene
bool pageArrows_ = false;
bool expandPanel_ = false;
std::shared_ptr<msimeui::StackPanel> root_;
// 装饰框:装饰图铺满,叠加的候选词按皮肤的对齐与偏移摆在上面。
std::shared_ptr<msimeui::OverlayPanel> decorationBox_;
std::shared_ptr<msimeui::Image> decoration_;
std::shared_ptr<msimeui::TextBlock> decorationWord_;
// 当前皮肤的叠加设置;皮肤没装饰图或没开 candidate_overlay 时为空。
std::optional<CandidateSkinCatalog::DecorationCandidate> decorationCandidate_;
float decorationBoxWidthDip_ = 0.0f;
float decorationBoxHeightDip_ = 0.0f;
// 正画着的词:同一个词不再重新量字号。
std::wstring decorationWordShown_;
std::shared_ptr<msimeui::Card> card_;
// 卡片与 v2 挂件层放在同一格里:挂件层测量为 0,格子仍由卡片决定,挂件按 anchor 画到卡片外沿。
// 网格按子元素顺序绘制:下层挂件、卡片、上层挂件。
+435 -115
View File
@@ -16,18 +16,22 @@
#include "msimeui/Controls.h"
#include "msimeui/DeviceResources.h"
#include "msimeui/Fonts.h"
#include "msimeui/ImageMask.h"
#include "msimeui/Layout.h"
#include "msimeui/Scene.h"
#include "msimeui/Window.h"
#include <algorithm>
#include <chrono>
#include <cmath>
#include <d2d1.h>
#include <dwrite.h>
#include <filesystem>
#include <functional>
#include <map>
#include <optional>
#include <string>
#include <vector>
#include <windowsx.h>
#include <wrl/client.h>
@@ -56,6 +60,8 @@ constexpr float kNoShadowPad = 1.0f;
static_assert(kNoShadowPad >= kToolbarStrokeWidth * 0.5f);
constexpr float kToolbarGlyphFontSizeFactor = 0.82f;
constexpr float kToolbarUnderlinedTextFontSizeFactor = 0.58f;
// 工具栏 HWND 上的动图帧定时器。宿主自己的定时器编号见 ime_windows_internal.h(2–14),这里避开它们。
constexpr UINT_PTR kToolbarAnimationTimerId = 0x0A12;
// Every toolbar icon carries a text fallback: "Segoe Fluent Icons" ships with
// Windows 11 only, and the Windows 10 build of "Segoe MDL2 Assets" may predate
// some of the IME-specific codepoints below. DirectWrite substitutes a font
@@ -93,11 +99,31 @@ class ToolbarIconButton : public msimeui::Visual
public:
using ClickHandler = std::function<void()>;
ToolbarIconButton(wchar_t glyph, std::wstring glyphFamily, std::wstring text, float size)
: glyph_(glyph), glyphFamily_(std::move(glyphFamily)), text_(std::move(text)), size_(size)
ToolbarIconButton(wchar_t glyph, std::wstring glyphFamily, std::wstring text, float width, float height)
: glyph_(glyph), glyphFamily_(std::move(glyphFamily)), text_(std::move(text)), width_(width), height_(height),
size_((std::min)(width, height)), glyphSize_(size_ * kToolbarGlyphFontSizeFactor)
{
SetWidth(size_);
SetHeight(size_);
SetWidth(width_);
SetHeight(height_);
}
// 皮肤单独指定的图标字号,不跟按钮框走。
void SetGlyphSize(float size)
{
glyphSize_ = size;
InvalidateVisual();
}
// 皮肤给的按钮图,铺在按钮框里(等比);空路径画回矢量图标。
void SetImage(const std::wstring &path)
{
image_.reset();
if (!path.empty())
{
image_ = std::make_shared<msimeui::Image>(path);
image_->SetStretch(msimeui::ImageStretch::Uniform);
}
InvalidateVisual();
}
void SetOnClick(ClickHandler handler)
@@ -122,12 +148,16 @@ class ToolbarIconButton : public msimeui::Visual
SizeF Measure(const SizeF &availableSize) override
{
(void)availableSize;
return {size_, size_};
return {width_, height_};
}
void Arrange(const msimeui::RectF &finalRect) override
{
bounds_ = finalRect;
if (image_)
{
image_->Arrange(finalRect);
}
}
void Render(msimeui::DeviceResources &deviceResources) override
@@ -137,17 +167,14 @@ class ToolbarIconButton : public msimeui::Visual
{
return;
}
if (hovered_ || pressed_)
if (image_)
{
if (ID2D1SolidColorBrush *hover = deviceResources.GetSolidColorBrush(hoverFill_))
{
const float radius = std::min(hoverRadius_, size_ * 0.5f);
const auto rounded = D2D1::RoundedRect(
D2D1::RectF(bounds_.x, bounds_.y, bounds_.x + bounds_.width, bounds_.y + bounds_.height), radius,
radius);
target->FillRoundedRectangle(rounded, hover);
}
// 贴图按钮的悬停底色盖在图上:图通常不透明,垫在下面就看不见了。
image_->Render(deviceResources);
FillHover(deviceResources, target);
return;
}
FillHover(deviceResources, target);
IDWriteFactory *factory = deviceResources.GetDWriteFactory();
ID2D1SolidColorBrush *brush = deviceResources.GetSolidColorBrush(glyphColor_);
@@ -158,8 +185,9 @@ class ToolbarIconButton : public msimeui::Visual
const bool useGlyph = glyph_ != 0 && !glyphFamily_.empty();
const std::wstring family = useGlyph ? glyphFamily_ : std::wstring(msimeui::UiFontFamily());
// 带下划线的 "En" 比图标字形小一号,比例跟着图标字号走。
const float fontSize =
size_ * (underline_ ? kToolbarUnderlinedTextFontSizeFactor : kToolbarGlyphFontSizeFactor);
underline_ ? glyphSize_ * (kToolbarUnderlinedTextFontSizeFactor / kToolbarGlyphFontSizeFactor) : glyphSize_;
IDWriteTextFormat *format =
deviceResources.GetTextFormat(family, fontSize, DWRITE_FONT_WEIGHT_NORMAL, DWRITE_TEXT_ALIGNMENT_CENTER,
DWRITE_PARAGRAPH_ALIGNMENT_CENTER, DWRITE_WORD_WRAPPING_NO_WRAP);
@@ -236,10 +264,30 @@ class ToolbarIconButton : public msimeui::Visual
}
private:
void FillHover(msimeui::DeviceResources &deviceResources, ID2D1RenderTarget *target)
{
if (!hovered_ && !pressed_)
{
return;
}
if (ID2D1SolidColorBrush *hover = deviceResources.GetSolidColorBrush(hoverFill_))
{
const float radius = std::min(hoverRadius_, size_ * 0.5f);
const auto rounded = D2D1::RoundedRect(
D2D1::RectF(bounds_.x, bounds_.y, bounds_.x + bounds_.width, bounds_.y + bounds_.height), radius,
radius);
target->FillRoundedRectangle(rounded, hover);
}
}
wchar_t glyph_ = 0;
std::wstring glyphFamily_;
std::wstring text_;
float width_ = 24.0f;
float height_ = 24.0f;
// 按钮框的短边;悬停底色的圆角与下划线粗细按它算。
float size_ = 24.0f;
float glyphSize_ = 24.0f * kToolbarGlyphFontSizeFactor;
bool underline_ = false;
bool hovered_ = false;
bool pressed_ = false;
@@ -247,6 +295,7 @@ class ToolbarIconButton : public msimeui::Visual
D2D1_COLOR_F hoverFill_ = D2D1::ColorF(1.0f, 1.0f, 1.0f, 0.10f);
float hoverRadius_ = 6.0f;
ClickHandler onClick_;
std::shared_ptr<msimeui::Image> image_;
};
class ToolbarDragHandle : public msimeui::Visual
@@ -345,13 +394,54 @@ void SendWorker(UINT type)
{
SendToTsfWorkerThreadViaNamedpipe(type, L"");
}
// 工具栏上的一个按钮:皮肤里的按钮键与当前状态名(见 CandidateSkinCatalog::ToolbarButtonStates),
// 以及没有贴图时画的图标。条形模式按顺序排成一行,图片模式按皮肤给的坐标摆。
struct ToolbarButtonSpec
{
std::string key;
std::string state;
// 图标字体里的字形;为 0 时画 text。
wchar_t codepoint = 0;
std::wstring text;
bool underline = false;
ToolbarIconButton::ClickHandler click;
};
ToolbarButtonSpec GlyphSpec(std::string key, std::string state, const ToolbarIcon &icon,
ToolbarIconButton::ClickHandler click)
{
return {std::move(key), std::move(state), icon.codepoint, icon.fallbackText, false, std::move(click)};
}
ToolbarButtonSpec TextSpec(std::string key, std::string state, std::wstring text, bool underline,
ToolbarIconButton::ClickHandler click)
{
return {std::move(key), std::move(state), 0, std::move(text), underline, std::move(click)};
}
// 图标字体缺字时退回文字(见 ToolbarIcon)。
std::shared_ptr<ToolbarIconButton> MakeToolbarButton(const ToolbarButtonSpec &spec, float width, float height)
{
if (spec.codepoint != 0)
{
const msimeui::IconGlyph resolved = msimeui::ResolveIconGlyph(spec.codepoint);
if (resolved.family)
{
return std::make_shared<ToolbarIconButton>(resolved.codepoint, resolved.family, L"", width, height);
}
}
auto button = std::make_shared<ToolbarIconButton>(0, L"", spec.text, width, height);
button->SetUnderline(spec.underline);
return button;
}
} // namespace
struct FloatingToolbarPresenter::Impl
{
msimeui::DeviceResources resources;
std::unique_ptr<msimeui::Window> window;
std::shared_ptr<msimeui::StackPanel> root;
std::shared_ptr<msimeui::Visual> root;
std::shared_ptr<msimeui::Card> card;
std::shared_ptr<msimeui::Container> frame;
std::shared_ptr<msimeui::Visual> dragLimit;
@@ -374,6 +464,38 @@ struct FloatingToolbarPresenter::Impl
int englishInputMode = 0;
int capsLock = 0;
int japaneseInputMode = 0;
// 皮肤的图片模式([toolbar] mode = "image"):一整张背景图,按钮按皮肤的坐标摆在上面,
// 不画底色、描边、阴影、把手与分隔线,窗口裁成背景图不透明部分的形状。
bool imageMode = false;
std::wstring packageRoot;
std::wstring imagePath;
float imageWidthDip = 0.0f;
float imageHeightDip = 0.0f;
std::map<std::string, CandidateSkinCatalog::ToolbarButton> buttons;
// 图片模式下摆出来的按钮:落在它们上面的点不算拖动区。
std::vector<std::shared_ptr<ToolbarIconButton>> imageButtons;
// v2 皮肤的动图才播放,与候选窗一致。
bool skinAnimates = false;
// 换了皮肤图或强制重载过,图片缓存与形状都要重读。
std::wstring imageKey;
// 背景图不透明部分的区域(窗口像素),以及它是按哪张图、多大算的;解码整张动图不便宜,只在这几项变了时重算。
HRGN imageShape = nullptr;
std::wstring shapePath;
int shapeWidthPx = 0;
int shapeHeightPx = 0;
// 窗口形状:imageShape 再并上每个按钮的框。
HRGN shape = nullptr;
~Impl()
{
for (HRGN region : {imageShape, shape})
{
if (region)
{
DeleteObject(region);
}
}
}
};
FloatingToolbarPresenter::FloatingToolbarPresenter() = default;
@@ -425,6 +547,152 @@ void FloatingToolbarPresenter::RebuildScene()
const float radius = impl_->radius * s;
const float iconRadius = impl_->iconRadius * s;
// 当前要显示的按钮,按工具栏上的顺序:中/英状态总在,其余跟设置页的开关走。
std::vector<ToolbarButtonSpec> specs;
if (impl_->capsLock == 1)
{
specs.push_back(TextSpec("status", "caps", L"A", false, [this]() {
if (impl_->englishInputMode == 1)
{
FanyNamedPipe::EnqueueExitEnglishInputModeTask();
}
else
{
FanyNamedPipe::EnqueueToolbarLanguageToggleTask(impl_->cnEn == 1);
}
}));
}
else if (impl_->cnEn != 1)
{
specs.push_back(
GlyphSpec("status", "en", kIconEn, []() { FanyNamedPipe::EnqueueToolbarLanguageToggleTask(false); }));
}
else if (impl_->englishInputMode == 1)
{
specs.push_back(TextSpec("status", "en-candidate", L"En", true,
[]() { FanyNamedPipe::EnqueueExitEnglishInputModeTask(); }));
}
else if (impl_->japaneseInputMode == 1)
{
specs.push_back(
GlyphSpec("status", "ja", kIconJa, []() { FanyNamedPipe::EnqueueToolbarLanguageToggleTask(true); }));
}
else
{
specs.push_back(
GlyphSpec("status", "cn", kIconCn, []() { FanyNamedPipe::EnqueueToolbarLanguageToggleTask(true); }));
}
const FloatingToolbarItemsConfig &items = GetConfiguredFloatingToolbarItems();
if (items.fullwidth)
{
if (impl_->doubleSingleByte == 1)
{
specs.push_back(GlyphSpec("fullwidth", "full", kIconFullWidth, []() {
SendWorker(Global::DataFromServerMsgTypeToTsfWorkerThread::SwitchToHalfwidth);
}));
}
else
{
specs.push_back(GlyphSpec("fullwidth", "half", kIconHalfWidth, []() {
SendWorker(Global::DataFromServerMsgTypeToTsfWorkerThread::SwitchToFullwidth);
}));
}
}
if (items.punctuation)
{
if (impl_->punctuation == 1)
{
specs.push_back(GlyphSpec("punctuation", "cn", kIconPuncCn, []() {
SendWorker(Global::DataFromServerMsgTypeToTsfWorkerThread::SwitchToPuncEn);
}));
}
else
{
specs.push_back(GlyphSpec("punctuation", "en", kIconPuncEn, []() {
SendWorker(Global::DataFromServerMsgTypeToTsfWorkerThread::SwitchToPuncCn);
}));
}
}
if (items.character_set)
{
const bool traditional = GetConfiguredCharacterSet() == "traditional";
specs.push_back(GlyphSpec("character_set", traditional ? "traditional" : "simplified",
traditional ? kIconTraditional : kIconSimplified, []() {
const std::string next =
GetConfiguredCharacterSet() == "traditional" ? "simplified" : "traditional";
SetConfiguredCharacterSet(next);
}));
}
if (items.emoji)
{
specs.push_back(GlyphSpec("emoji", "", kIconEmoji, []() { OpenEmojiPanelApplication(); }));
}
if (items.screen_keyboard)
{
specs.push_back(GlyphSpec("screen_keyboard", "", kIconKeyboard, []() { OpenKeyboardPanelApplication(); }));
}
if (items.settings)
{
specs.push_back(GlyphSpec("settings", "", kIconSettings, []() { OpenSettingsApplication(); }));
}
impl_->imageButtons.clear();
if (impl_->imageMode)
{
// 背景图铺满,按钮按皮肤的坐标摆在图上;皮肤没给坐标或把它藏起来的按钮不画。
auto root = std::make_shared<msimeui::OverlayPanel>();
const float width = impl_->imageWidthDip * s;
const float height = impl_->imageHeightDip * s;
root->SetWidth(width);
root->SetHeight(height);
auto background = std::make_shared<msimeui::Image>(impl_->imagePath);
background->SetStretch(msimeui::ImageStretch::Fill);
background->SetWidth(width);
background->SetHeight(height);
root->AddChildAt(background, 0.0f, 0.0f);
for (ToolbarButtonSpec &spec : specs)
{
const auto found = impl_->buttons.find(spec.key);
if (found == impl_->buttons.end())
{
continue;
}
const CandidateSkinCatalog::ToolbarButton &placement = found->second;
if (!placement.visible.value_or(true) || !placement.xDip || !placement.yDip)
{
continue;
}
const auto dip = [s](std::optional<double> value) {
return static_cast<float>(value.value_or(CandidateSkinCatalog::kDefaultToolbarButtonSizeDip)) * s;
};
auto button = MakeToolbarButton(spec, dip(placement.widthDip), dip(placement.heightDip));
button->SetColors(impl_->glyph, impl_->hover, iconRadius);
button->SetOnClick(std::move(spec.click));
if (placement.iconSizeDip)
{
button->SetGlyphSize(static_cast<float>(*placement.iconSizeDip) * s);
}
const auto stateImage = placement.images.find(spec.state);
const std::string &image = stateImage != placement.images.end() ? stateImage->second : placement.image;
if (!image.empty())
{
button->SetImage(impl_->packageRoot + string_to_wstring(image));
}
root->AddChildAt(button, static_cast<float>(*placement.xDip) * s, static_cast<float>(*placement.yDip) * s);
impl_->imageButtons.push_back(button);
}
impl_->card.reset();
impl_->frame.reset();
impl_->dragLimit.reset();
impl_->shadow = false;
impl_->root = root;
auto scene = std::make_unique<msimeui::Scene>();
scene->SetRoot(impl_->root);
impl_->window->SetScene(std::move(scene));
return;
}
auto row = std::make_shared<msimeui::HorizontalStackPanel>(gap);
row->SetVerticalContentAlignment(msimeui::VerticalAlignment::Center);
row->SetPadding({0.0f, 0.0f, padRight, 0.0f});
@@ -441,101 +709,12 @@ void FloatingToolbarPresenter::RebuildScene()
auto icons = std::make_shared<msimeui::HorizontalStackPanel>(gap);
icons->SetVerticalContentAlignment(msimeui::VerticalAlignment::Center);
auto addText = [&](std::wstring text, bool underline, ToolbarIconButton::ClickHandler click) {
auto button = std::make_shared<ToolbarIconButton>(0, L"", std::move(text), iconSize);
button->SetUnderline(underline);
for (ToolbarButtonSpec &spec : specs)
{
auto button = MakeToolbarButton(spec, iconSize, iconSize);
button->SetColors(impl_->glyph, impl_->hover, iconRadius);
button->SetOnClick(std::move(click));
button->SetOnClick(std::move(spec.click));
icons->AddChild(button);
return button;
};
auto addGlyph = [&](const ToolbarIcon &icon, ToolbarIconButton::ClickHandler click) {
const msimeui::IconGlyph resolved = msimeui::ResolveIconGlyph(icon.codepoint);
if (!resolved.family)
{
return addText(icon.fallbackText, false, std::move(click));
}
auto button = std::make_shared<ToolbarIconButton>(resolved.codepoint, resolved.family, L"", iconSize);
button->SetColors(impl_->glyph, impl_->hover, iconRadius);
button->SetOnClick(std::move(click));
icons->AddChild(button);
return button;
};
if (impl_->capsLock == 1)
{
addText(L"A", false, [this]() {
if (impl_->englishInputMode == 1)
{
FanyNamedPipe::EnqueueExitEnglishInputModeTask();
}
else
{
FanyNamedPipe::EnqueueToolbarLanguageToggleTask(impl_->cnEn == 1);
}
});
}
else if (impl_->cnEn != 1)
{
addGlyph(kIconEn, []() { FanyNamedPipe::EnqueueToolbarLanguageToggleTask(false); });
}
else if (impl_->englishInputMode == 1)
{
addText(L"En", true, []() { FanyNamedPipe::EnqueueExitEnglishInputModeTask(); });
}
else if (impl_->japaneseInputMode == 1)
{
addGlyph(kIconJa, []() { FanyNamedPipe::EnqueueToolbarLanguageToggleTask(true); });
}
else
{
addGlyph(kIconCn, []() { FanyNamedPipe::EnqueueToolbarLanguageToggleTask(true); });
}
const FloatingToolbarItemsConfig &items = GetConfiguredFloatingToolbarItems();
if (items.fullwidth)
{
if (impl_->doubleSingleByte == 1)
{
addGlyph(kIconFullWidth,
[]() { SendWorker(Global::DataFromServerMsgTypeToTsfWorkerThread::SwitchToHalfwidth); });
}
else
{
addGlyph(kIconHalfWidth,
[]() { SendWorker(Global::DataFromServerMsgTypeToTsfWorkerThread::SwitchToFullwidth); });
}
}
if (items.punctuation)
{
if (impl_->punctuation == 1)
{
addGlyph(kIconPuncCn, []() { SendWorker(Global::DataFromServerMsgTypeToTsfWorkerThread::SwitchToPuncEn); });
}
else
{
addGlyph(kIconPuncEn, []() { SendWorker(Global::DataFromServerMsgTypeToTsfWorkerThread::SwitchToPuncCn); });
}
}
if (items.character_set)
{
const bool traditional = GetConfiguredCharacterSet() == "traditional";
addGlyph(traditional ? kIconTraditional : kIconSimplified, []() {
const std::string next = GetConfiguredCharacterSet() == "traditional" ? "simplified" : "traditional";
SetConfiguredCharacterSet(next);
});
}
if (items.emoji)
{
addGlyph(kIconEmoji, []() { OpenEmojiPanelApplication(); });
}
if (items.screen_keyboard)
{
addGlyph(kIconKeyboard, []() { OpenKeyboardPanelApplication(); });
}
if (items.settings)
{
addGlyph(kIconSettings, []() { OpenSettingsApplication(); });
}
row->AddChild(leading);
@@ -572,8 +751,9 @@ void FloatingToolbarPresenter::RebuildScene()
impl_->frame->SetPadding(kNoShadowPad);
}
impl_->frame->SetChild(impl_->card);
impl_->root = std::make_shared<msimeui::StackPanel>(0.0f);
impl_->root->AddChild(impl_->frame);
auto root = std::make_shared<msimeui::StackPanel>(0.0f);
root->AddChild(impl_->frame);
impl_->root = root;
auto scene = std::make_unique<msimeui::Scene>();
scene->SetRoot(impl_->root);
impl_->window->SetScene(std::move(scene));
@@ -604,6 +784,28 @@ void FloatingToolbarPresenter::ApplyTheme()
{
ApplyFloatingToolbarSkinOverrides(skin, *package, light);
}
impl_->imageMode = package && package->toolbarMode == "image";
impl_->skinAnimates = impl_->imageMode && package->schemaVersion >= 2;
impl_->buttons.clear();
std::wstring imageKey;
if (impl_->imageMode)
{
impl_->packageRoot = (skinsRoot / std::filesystem::u8path(package->id)).wstring() + L"\\";
impl_->imagePath = impl_->packageRoot + string_to_wstring(package->toolbarImage);
impl_->imageWidthDip = static_cast<float>(package->toolbarImageWidthDip);
impl_->imageHeightDip = static_cast<float>(package->toolbarImageHeightDip);
impl_->buttons = package->toolbarButtons;
imageKey = impl_->imagePath + L"|" + std::to_wstring(GetCandidateSkinReloadRevision());
}
if (imageKey != impl_->imageKey)
{
// 换了皮肤图或强制重载过(设置页改了皮肤文件):丢掉解码过的图片与形状,动画重新起算。
impl_->imageKey = imageKey;
StopAnimationFrames();
impl_->resources.ClearImageCaches();
impl_->resources.SetAnimationEpoch(msimeui::DeviceResources::AnimationClock::now());
impl_->shapePath.clear();
}
impl_->fill = skin.fill;
impl_->border = skin.border;
impl_->glyph = skin.glyph;
@@ -629,7 +831,8 @@ void FloatingToolbarPresenter::RelayoutHost(FLOAT scaleOverride, const RECT *sug
return;
}
impl_->root->InvalidateMeasure();
const SizeF measured = impl_->root->MeasureInLayout({2000.0f, 240.0f});
// 条形高不过 240;图片模式按皮肤的图开窗口,上限与 image_height_dip 相同。
const SizeF measured = impl_->root->MeasureInLayout({2000.0f, impl_->imageMode ? 2000.0f : 240.0f});
impl_->root->InvalidateArrange();
impl_->root->ArrangeInLayout({0.0f, 0.0f, measured.width, measured.height});
::FTB_CONTENT_WIDTH_DIP = measured.width;
@@ -666,9 +869,18 @@ void FloatingToolbarPresenter::RelayoutHost(FLOAT scaleOverride, const RECT *sug
SetWindowPos(hwnd_, nullptr, pos.x, pos.y, widthPx, heightPx, SWP_NOZORDER | SWP_NOACTIVATE);
::FTB_HOST_INSETS_PX = insets;
::FTB_HOST_INSETS_VALID = true;
ClipWindowToVisibleScreens(hwnd_, ScreenArea::WorkArea);
// 背景图画在 (0, 0) 起、测量出的大小上;形状要和它在屏幕上的像素一一对上。
UpdateShape(static_cast<int>(std::lround(measured.width * scale)),
static_cast<int>(std::lround(measured.height * scale)), scale);
ClipWindowToVisibleScreens(hwnd_, ScreenArea::WorkArea, ShapeRegion());
impl_->resources.EnsureForComposition(hwnd_);
if (impl_->card && impl_->dragLimit)
impl_->captionRect = {};
if (impl_->imageMode)
{
// 整张图都能拖,按钮除外(见 HitCaptionDrag)。
impl_->captionRect = {0.0f, 0.0f, measured.width, measured.height};
}
else if (impl_->card && impl_->dragLimit)
{
const RectF card = impl_->card->GetBounds();
const RectF limit = impl_->dragLimit->GetBounds();
@@ -684,13 +896,74 @@ bool FloatingToolbarPresenter::HitCaptionDrag(POINT clientPoint) const
return false;
}
const PointF dip = impl_->window->ClientPixelsToDips(clientPoint);
// 按钮上的点要留给按钮自己:答成 HTCAPTION 的话按下就进了系统的拖动循环,按钮收不到点击。
for (const auto &button : impl_->imageButtons)
{
if (button->HitTest(dip))
{
return false;
}
}
return dip.x >= impl_->captionRect.x && dip.x < impl_->captionRect.x + impl_->captionRect.width &&
dip.y >= impl_->captionRect.y && dip.y < impl_->captionRect.y + impl_->captionRect.height;
}
HRGN FloatingToolbarPresenter::ShapeRegion() const
{
return bound_ && impl_ && impl_->imageMode ? impl_->shape : nullptr;
}
void FloatingToolbarPresenter::UpdateShape(int imageWidthPx, int imageHeightPx, float scale)
{
if (!impl_->imageMode || imageWidthPx <= 0 || imageHeightPx <= 0)
{
return;
}
if (impl_->shapePath != impl_->imagePath || impl_->shapeWidthPx != imageWidthPx ||
impl_->shapeHeightPx != imageHeightPx)
{
if (impl_->imageShape)
{
DeleteObject(impl_->imageShape);
}
// 窗口区域连画面一起裁,所以动图按所有帧合起来的轮廓裁,哪一帧画到的地方都留在窗口里。
// 半透明的抗锯齿边缘也算在窗口里,边缘才不会被裁出锯齿。
impl_->imageShape = msimeui::CreateAlphaMaskRegion(impl_->imagePath, static_cast<UINT>(imageWidthPx),
static_cast<UINT>(imageHeightPx));
impl_->shapePath = impl_->imagePath;
impl_->shapeWidthPx = imageWidthPx;
impl_->shapeHeightPx = imageHeightPx;
FTB_DIAG_LOGF(L"ftb-d2d image shape {}x{} region={}", imageWidthPx, imageHeightPx, impl_->imageShape ? 1 : 0);
}
if (impl_->shape)
{
DeleteObject(impl_->shape);
impl_->shape = nullptr;
}
// 读不出图时就是矩形窗口。
if (!impl_->imageShape)
{
return;
}
// 皮肤摆出来的按钮整个算在窗口里:落在背景图透明处的按钮也不会被裁掉、点不到。
impl_->shape = CreateRectRgn(0, 0, 0, 0);
CombineRgn(impl_->shape, impl_->imageShape, nullptr, RGN_COPY);
for (const auto &button : impl_->imageButtons)
{
const RectF box = button->GetBounds();
HRGN rect =
CreateRectRgn(static_cast<int>(std::floor(box.x * scale)), static_cast<int>(std::floor(box.y * scale)),
static_cast<int>(std::ceil((box.x + box.width) * scale)),
static_cast<int>(std::ceil((box.y + box.height) * scale)));
CombineRgn(impl_->shape, impl_->shape, rect, RGN_OR);
DeleteObject(rect);
}
}
RECT FloatingToolbarPresenter::ShadowInsetsPx(int hostWidthPx, int hostHeightPx, FLOAT scale) const
{
if (!impl_ || scale <= 0.0f)
// 图片模式没有阴影与描边;图里的透明边由窗口形状处理,不当成可以伸出屏幕的边距。
if (!impl_ || scale <= 0.0f || impl_->imageMode)
{
return {};
}
@@ -751,6 +1024,10 @@ void FloatingToolbarPresenter::Present()
return;
}
impl_->resources.Resize(width, height);
// 系统的「动画效果」开关随时可能改,每帧都重读;bar 与 v1 皮肤从不播放。
impl_->resources.SetAnimationsEnabled(impl_->skinAnimates && SystemAnimationsEnabled());
// Samples the animation clock for this frame and starts the next-frame deadline over.
impl_->resources.BeginFrame();
ID2D1RenderTarget *target = impl_->resources.GetRenderTarget();
if (!target)
{
@@ -786,6 +1063,37 @@ void FloatingToolbarPresenter::Present()
return;
}
impl_->resources.Present();
ScheduleAnimationFrame();
}
void FloatingToolbarPresenter::ScheduleAnimationFrame()
{
if (!hwnd_ || !impl_ || !impl_->window)
{
return;
}
const msimeui::Scene *scene = impl_->window->GetScene();
const auto deadline = scene ? scene->GetNextFrameDeadline() : std::nullopt;
// 工具栏常驻:隐藏或淡出到看不见时不画,免得一直占着 CPU 和 GPU。再显示时的 Present 会接着排。
if (!deadline || impl_->opacity <= 0.0f || !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; the slack lets Windows batch the wake-up.
SetCoalescableTimer(hwnd_, kToolbarAnimationTimerId, delay, nullptr, (std::min)(delay / 10U, 8U));
}
void FloatingToolbarPresenter::StopAnimationFrames()
{
if (hwnd_)
{
KillTimer(hwnd_, kToolbarAnimationTimerId);
}
}
void FloatingToolbarPresenter::SetOpacity(float opacity)
@@ -813,6 +1121,18 @@ bool FloatingToolbarPresenter::HandleMessage(UINT message, WPARAM wParam, LPARAM
{
case WM_ERASEBKGND:
return true;
case WM_TIMER:
if (wParam != kToolbarAnimationTimerId)
{
return false;
}
// One-shot: the Present below schedules the next frame if anything still animates.
StopAnimationFrames();
if (IsWindowVisible(hwnd_))
{
Present();
}
return true;
case WM_PAINT: {
PAINTSTRUCT ps{};
BeginPaint(hwnd_, &ps);
@@ -25,6 +25,11 @@ class FloatingToolbarPresenter
// screen edge (KeepRectOnVisibleScreens' offscreenInsets), for a host of
// the given physical size. Everything inside it is the bar and its border.
RECT ShadowInsetsPx(int hostWidthPx, int hostHeightPx, FLOAT scale) const;
// A skin's image-mode toolbar is shaped like its background image: the
// region (window coordinates) of the image's non-transparent pixels and the
// button boxes, still owned by the presenter. Null for the bar, and when the
// image cannot be read.
HRGN ShapeRegion() const;
bool HandleMessage(UINT message, WPARAM wParam, LPARAM lParam);
private:
@@ -34,6 +39,12 @@ class FloatingToolbarPresenter
FloatingToolbarPresenter &operator=(const FloatingToolbarPresenter &) = delete;
void RebuildScene();
// Rebuilds ShapeRegion for a background image drawn at this many physical px,
// plus the button boxes at `scale` physical px per DIP.
void UpdateShape(int imageWidthPx, int imageHeightPx, float scale);
// Animated skin images: one timer per frame while the toolbar is on screen.
void ScheduleAnimationFrame();
void StopAnimationFrames();
struct Impl;
std::unique_ptr<Impl> impl_;
+27 -6
View File
@@ -64,6 +64,12 @@ RECT FloatingToolbarShadowInsetsPx(int hostWidthPx, int hostHeightPx, FLOAT scal
return {};
}
// 皮肤图片模式的工具栏是异形窗口:裁到屏幕内的同时要保住图片的形状,不能拿矩形把它盖掉。
void ClipFloatingToolbarToVisibleScreens(HWND hwnd)
{
ClipWindowToVisibleScreens(hwnd, ScreenArea::WorkArea, FloatingToolbarPresenter::Instance().ShapeRegion());
}
void KeepFloatingToolbarInsideVisibleScreens(HWND hwnd)
{
RECT rect{};
@@ -82,12 +88,12 @@ void KeepFloatingToolbarInsideVisibleScreens(HWND hwnd)
rect, ScreenArea::WorkArea, &monitor,
FloatingToolbarShadowInsetsPx(rect.right - rect.left, rect.bottom - rect.top, GetWindowScale(hwnd))))
{
ClipWindowToVisibleScreens(hwnd, ScreenArea::WorkArea);
ClipFloatingToolbarToVisibleScreens(hwnd);
return;
}
SetWindowPos(hwnd, nullptr, rect.left, rect.top, 0, 0, SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE);
ClipWindowToVisibleScreens(hwnd, ScreenArea::WorkArea);
ClipFloatingToolbarToVisibleScreens(hwnd);
MONITORINFO info{sizeof(info)};
GetMonitorInfo(monitor, &info);
@@ -183,7 +189,7 @@ void LayoutFloatingToolbar(HWND hwnd, bool reset_to_default_corner, FLOAT scaleO
const BOOL ok = SetWindowPos(hwnd, nullptr, posX, posY, width, height, SWP_NOZORDER | SWP_NOACTIVATE);
::FTB_HOST_INSETS_PX = shadowInsets;
::FTB_HOST_INSETS_VALID = true;
ClipWindowToVisibleScreens(hwnd, ScreenArea::WorkArea);
ClipFloatingToolbarToVisibleScreens(hwnd);
if (FloatingToolbarPresenter::Instance().IsBound())
{
FloatingToolbarPresenter::Instance().Present();
@@ -244,12 +250,27 @@ void RestoreFloatingToolbarAlpha()
// GetCursorPos and GetWindowRect are both physical pixels under per-monitor-v2,
// so this needs no DPI conversion. Sampling the rect instead of tracking mouse
// messages also covers the caption drag area.
// messages also covers the caption drag area. A window region (an image-mode
// skin's shape, or the part left on screen) narrows it: the cursor over a
// clipped-away pixel is over whatever lies behind.
bool IsCursorOverFloatingToolbar()
{
POINT cursor{};
RECT rect{};
return GetCursorPos(&cursor) && GetWindowRect(::global_hwnd_ftb, &rect) && PtInRect(&rect, cursor);
if (!GetCursorPos(&cursor) || !GetWindowRect(::global_hwnd_ftb, &rect) || !PtInRect(&rect, cursor))
{
return false;
}
bool inside = true;
if (HRGN region = CreateRectRgn(0, 0, 0, 0))
{
if (GetWindowRgn(::global_hwnd_ftb, region) != ERROR)
{
inside = PtInRegion(region, cursor.x - rect.left, cursor.y - rect.top) != FALSE;
}
DeleteObject(region);
}
return inside;
}
void CancelFloatingToolbarAutoHideFade()
@@ -634,7 +655,7 @@ LRESULT CALLBACK WndProcFtbWindow(HWND hwnd, UINT message, WPARAM wParam, LPARAM
const auto *pos = reinterpret_cast<const WINDOWPOS *>(lParam);
if (pos && (pos->flags & (SWP_NOMOVE | SWP_NOSIZE)) != (SWP_NOMOVE | SWP_NOSIZE))
{
ClipWindowToVisibleScreens(hwnd, ScreenArea::WorkArea);
ClipFloatingToolbarToVisibleScreens(hwnd);
}
return DefWindowProc(hwnd, message, wParam, lParam);
}
+1
View File
@@ -94,6 +94,7 @@ set(TEST_SOURCE_FILES
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_skin_catalog_images.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_skin_catalog_page_arrows.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_skin_catalog_supports.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_skin_catalog_toolbar_image.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_skin_palette.cpp"
"${CMAKE_CURRENT_SOURCE_DIR}/src/test_non_ascii_path_contract.cpp"
"${CMAKE_SOURCE_DIR}/src/skin/candidate_skin_catalog.cpp"
@@ -0,0 +1,289 @@
#include "tests/includes/test_framework.h"
#include "skin/candidate_skin_catalog.h"
#include <windows.h>
#include <algorithm>
#include <filesystem>
#include <fstream>
#include <string>
#include <system_error>
#include <vector>
namespace
{
constexpr const char *kManifestHead = R"(schema_version = 1
id = "art"
name = "Art"
version = "1.0.0"
base = "fluent"
[supports]
layouts = ["horizontal", "vertical"]
themes = ["dark", "light"]
)";
// `tables` is appended verbatim after the head, so each case owns its [candidate_window] and [toolbar] tables.
std::filesystem::path WriteSkin(const std::wstring &leaf, const std::string &tables)
{
namespace fs = std::filesystem;
const fs::path skins_root =
fs::temp_directory_path() / (L"msime-skin-toolbar-" + 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\\bar.png", L"assets\\cn.png", L"assets\\en.png", L"assets\\gear.apng", L"assets\\deco.png"})
{
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 << kManifestHead << tables;
return skins_root;
}
void RemoveSkin(const std::filesystem::path &root)
{
std::error_code ec;
std::filesystem::remove_all(root, ec);
}
// The skin is rejected with a reason the settings page can show.
bool LoadFails(const std::wstring &leaf, const std::string &tables)
{
const auto root = WriteSkin(leaf, tables);
std::string error;
const bool failed = !CandidateSkinCatalog::Load(root, "art", &error).has_value() && !error.empty();
RemoveSkin(root);
return failed;
}
bool HasNotice(const CandidateSkinCatalog::Package &package, const std::string &fragment)
{
return std::any_of(package.notices.begin(), package.notices.end(),
[&](const std::string &notice) { return notice.find(fragment) != std::string::npos; });
}
constexpr const char *kImageToolbar = R"([candidate_window]
[toolbar]
mode = "image"
image = "assets/bar.png"
image_width_dip = 160
image_height_dip = 40
)";
} // namespace
TEST_CASE(candidate_skin_catalog_reads_toolbar_image_mode)
{
const auto root = WriteSkin(L"valid", std::string(kImageToolbar) + R"(
[toolbar.buttons.status]
x_dip = 8
y_dip = 6
width_dip = 30
height_dip = 28
icon_size_dip = 18.5
image = "assets/cn.png"
[toolbar.buttons.status.images]
en = "assets/en.png"
[toolbar.buttons.settings]
x_dip = 120.5
y_dip = 7
image = "assets/gear.apng"
[toolbar.buttons.emoji]
visible = false
)");
std::string error;
const auto package = CandidateSkinCatalog::Load(root, "art", &error);
REQUIRE(error.empty());
REQUIRE(package.has_value());
REQUIRE_EQ(package->toolbarMode, std::string("image"));
REQUIRE_EQ(package->toolbarImage, std::string("assets/bar.png"));
REQUIRE_EQ(package->toolbarImageWidthDip, 160.0);
REQUIRE_EQ(package->toolbarImageHeightDip, 40.0);
const auto &status = package->toolbarButtons.at("status");
REQUIRE_EQ(*status.xDip, 8.0);
REQUIRE_EQ(*status.yDip, 6.0);
REQUIRE_EQ(*status.widthDip, 30.0);
REQUIRE_EQ(*status.heightDip, 28.0);
REQUIRE_EQ(*status.iconSizeDip, 18.5);
REQUIRE_EQ(status.image, std::string("assets/cn.png"));
REQUIRE_EQ(status.images.at("en"), std::string("assets/en.png"));
const auto &settings = package->toolbarButtons.at("settings");
REQUIRE_EQ(*settings.xDip, 120.5);
REQUIRE(!settings.widthDip.has_value());
REQUIRE(!settings.iconSizeDip.has_value());
REQUIRE_EQ(settings.image, std::string("assets/gear.apng"));
REQUIRE(!*package->toolbarButtons.at("emoji").visible);
REQUIRE(package->notices.empty());
RemoveSkin(root);
}
TEST_CASE(candidate_skin_catalog_toolbar_defaults_to_bar)
{
const auto root = WriteSkin(L"bar", "[candidate_window]\n");
std::string error;
const auto package = CandidateSkinCatalog::Load(root, "art", &error);
REQUIRE(package.has_value());
REQUIRE_EQ(package->toolbarMode, std::string("bar"));
REQUIRE(package->toolbarImage.empty());
REQUIRE(package->toolbarButtons.empty());
RemoveSkin(root);
}
// The image mode is opened at the declared size, so the size and the image itself are required, and every
// broken key names itself instead of failing with an empty reason.
TEST_CASE(candidate_skin_catalog_rejects_invalid_toolbar_image_mode)
{
REQUIRE(LoadFails(L"no-size", "[candidate_window]\n[toolbar]\nmode = \"image\"\nimage = \"assets/bar.png\"\n"));
REQUIRE(LoadFails(L"no-image", "[candidate_window]\n[toolbar]\nmode = \"image\"\nimage_width_dip = 160\n"
"image_height_dip = 40\n"));
REQUIRE(LoadFails(L"bad-mode", "[candidate_window]\n[toolbar]\nmode = \"floating\"\n"));
REQUIRE(LoadFails(L"missing-file", "[candidate_window]\n[toolbar]\nmode = \"image\"\nimage = \"assets/none.png\"\n"
"image_width_dip = 160\nimage_height_dip = 40\n"));
REQUIRE(LoadFails(L"bad-extension", "[candidate_window]\n[toolbar]\nmode = \"image\"\nimage = \"assets/bar.bmp\"\n"
"image_width_dip = 160\nimage_height_dip = 40\n"));
REQUIRE(LoadFails(L"buttons-array", std::string(kImageToolbar) + "buttons = [\"status\"]\n"));
REQUIRE(LoadFails(L"button-zero-size",
std::string(kImageToolbar) + "[toolbar.buttons.status]\nx_dip = 0\ny_dip = 0\nwidth_dip = 0\n"));
REQUIRE(LoadFails(L"button-negative", std::string(kImageToolbar) + "[toolbar.buttons.status]\nx_dip = -4\n"));
REQUIRE(
LoadFails(L"button-zero-icon", std::string(kImageToolbar) + "[toolbar.buttons.status]\nicon_size_dip = 0\n"));
// A stateless button has nothing to key the images by.
REQUIRE(LoadFails(L"stateless-images",
std::string(kImageToolbar) + "[toolbar.buttons.settings.images]\ncn = \"assets/cn.png\"\n"));
REQUIRE(LoadFails(L"missing-button-image",
std::string(kImageToolbar) + "[toolbar.buttons.status.images]\ncn = \"assets/none.png\"\n"));
}
// Keys a newer skin may use are noticed and skipped rather than making the whole skin unusable.
TEST_CASE(candidate_skin_catalog_notices_unused_toolbar_keys)
{
const auto root = WriteSkin(L"notices", std::string(kImageToolbar) + R"(
[toolbar.buttons.voice]
x_dip = 0
y_dip = 0
[toolbar.buttons.status]
x_dip = 150
y_dip = 6
[toolbar.buttons.status.images]
kana = "assets/cn.png"
[toolbar.buttons.punctuation]
x_dip = 40
)");
std::string error;
const auto package = CandidateSkinCatalog::Load(root, "art", &error);
REQUIRE(error.empty());
REQUIRE(package.has_value());
REQUIRE(!package->toolbarButtons.count("voice"));
REQUIRE(package->toolbarButtons.at("status").images.empty());
REQUIRE(HasNotice(*package, "toolbar.buttons.voice"));
REQUIRE(HasNotice(*package, "kana"));
REQUIRE(HasNotice(*package, "toolbar.buttons.punctuation"));
// 150 + the default 26 runs past the 160 DIP image.
REQUIRE(HasNotice(*package, "超出了背景图"));
RemoveSkin(root);
const auto bar = WriteSkin(L"bar-buttons", "[candidate_window]\n[toolbar.buttons.status]\nx_dip = 0\ny_dip = 0\n");
const auto barPackage = CandidateSkinCatalog::Load(bar, "art", &error);
REQUIRE(barPackage.has_value());
REQUIRE_EQ(barPackage->toolbarMode, std::string("bar"));
REQUIRE(HasNotice(*barPackage, "mode = \"image\""));
RemoveSkin(bar);
}
TEST_CASE(candidate_skin_catalog_reads_decoration_candidate_overlay)
{
const auto root = WriteSkin(L"overlay", R"([candidate_window.decoration]
image = "assets/deco.png"
top_inset_dip = 88
width_dip = 136
candidate_overlay = true
candidate_overlay_font_size_dip = 18
candidate_overlay_min_font_size_dip = 6
candidate_overlay_max_width_dip = 120
candidate_overlay_color = "#de4045"
candidate_overlay_align_h = "right"
candidate_overlay_align_v = "bottom"
candidate_overlay_offset_x_dip = -3
candidate_overlay_offset_y_dip = 5
candidate_overlay_strip_badge = false
candidate_overlay_index = 0
)");
std::string error;
const auto package = CandidateSkinCatalog::Load(root, "art", &error);
REQUIRE(error.empty());
REQUIRE(package.has_value());
REQUIRE(package->decorationCandidate.has_value());
const auto &overlay = *package->decorationCandidate;
REQUIRE_EQ(overlay.fontSizeDip, 18.0);
REQUIRE_EQ(overlay.minFontSizeDip, 6.0);
REQUIRE_EQ(overlay.maxWidthDip, 120.0);
REQUIRE_EQ(overlay.color, std::string("#de4045"));
REQUIRE_EQ(overlay.alignH, std::string("right"));
REQUIRE_EQ(overlay.alignV, std::string("bottom"));
REQUIRE_EQ(overlay.offsetXDip, -3.0);
REQUIRE_EQ(overlay.offsetYDip, 5.0);
REQUIRE(!overlay.stripBadge);
REQUIRE_EQ(overlay.index, 0);
RemoveSkin(root);
}
TEST_CASE(candidate_skin_catalog_decoration_candidate_overlay_is_opt_in)
{
// The candidate_overlay_* keys mean nothing until candidate_overlay = true.
const auto root = WriteSkin(L"overlay-off", R"([candidate_window.decoration]
image = "assets/deco.png"
top_inset_dip = 88
width_dip = 136
candidate_overlay_font_size_dip = 18
)");
std::string error;
const auto package = CandidateSkinCatalog::Load(root, "art", &error);
REQUIRE(package.has_value());
REQUIRE(!package->decorationCandidate.has_value());
RemoveSkin(root);
const auto defaults = WriteSkin(L"overlay-defaults", R"([candidate_window.decoration]
image = "assets/deco.png"
top_inset_dip = 88
width_dip = 136
candidate_overlay = true
)");
const auto defaultsPackage = CandidateSkinCatalog::Load(defaults, "art", &error);
REQUIRE(defaultsPackage.has_value());
const auto &overlay = *defaultsPackage->decorationCandidate;
REQUIRE_EQ(overlay.index, 1);
REQUIRE_EQ(overlay.fontSizeDip, 16.0);
REQUIRE_EQ(overlay.maxWidthDip, 0.0);
REQUIRE_EQ(overlay.alignH, std::string("center"));
REQUIRE(overlay.stripBadge);
RemoveSkin(defaults);
}
TEST_CASE(candidate_skin_catalog_rejects_invalid_decoration_candidate_overlay)
{
const std::string head = "[candidate_window.decoration]\nimage = \"assets/deco.png\"\ntop_inset_dip = 88\n"
"width_dip = 136\ncandidate_overlay = true\n";
REQUIRE(LoadFails(L"overlay-flag", "[candidate_window.decoration]\nimage = \"assets/deco.png\"\n"
"top_inset_dip = 88\nwidth_dip = 136\ncandidate_overlay = \"yes\"\n"));
// DirectWrite has no text format for size 0.
REQUIRE(LoadFails(L"overlay-zero-font", head + "candidate_overlay_font_size_dip = 0\n"));
REQUIRE(LoadFails(L"overlay-zero-min-font", head + "candidate_overlay_min_font_size_dip = 0\n"));
REQUIRE(LoadFails(L"overlay-align", head + "candidate_overlay_align_h = \"middle\"\n"));
REQUIRE(LoadFails(L"overlay-index", head + "candidate_overlay_index = 1.5\n"));
REQUIRE(LoadFails(L"overlay-offset", head + "candidate_overlay_offset_x_dip = 3000\n"));
}
+2 -2
View File
@@ -79,12 +79,12 @@ my-skin/
| `[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 的内置清单,出厂关闭)、`expand_panel`(横排卷轴展开,见[卷轴展开](#卷轴展开expand_panel);不写时沿用 base 的内置清单,出厂关闭)、`expand_rows`(展开后一屏的网格行数,2–8,缺省 4) |
| `[candidate_window]` 版式 | 长度可以写 DIP 数值,也可以写 `"0.3em"` 按候选字号缩放(换算后取整):`padding`(卡片内边距)、`item_gap`(候选项间距)、`item_extra_height`(候选项比 1.35 倍字号多出的高度)、`number_inset`(高亮左缘到序号的距离,不小于 5)、`item_padding_right`(候选文字后的留白)、`item_min_width`(横排每项最小宽度)、`number_gap`(序号与候选的间距)、`selected_bar_width`(选中竖条宽);以及 `number_scale`(序号字号比例)、`selected_bar_height_em`(选中竖条高,按字号倍数)、`selected_bar_inside`、`item_corners_follow_card`(落在卡片四角上的高亮角跟卡片同圆角)、`justify_rows`(横排时高亮铺满到卡片右边)、`trim_hidden_preedit_gap`、`page_arrow_style`(`chevron` / `triangle`)、`preedit_margin_dip`(预编辑行外边距,上右下左) |
| `[candidate_window.horizontal]` / `[candidate_window.vertical]` | 同上的版式键,只对横排 / 竖排生效,并且优先于 `[candidate_window]` 里写的 |
| `[candidate_window.decoration]` | 卡片上方的装饰图:`image`、`top_inset_dip`、`width_dip`、`align`(`left` / `center` / `right`) |
| `[candidate_window.decoration]` | 卡片上方的装饰图:`image`、`top_inset_dip`、`width_dip`、`align`(`left` / `center` / `right`);`candidate_overlay = true` 时把一个候选词叠加在装饰图上,字号、颜色、对齐、偏移等见 [schema 说明](../examples/skin-examples/schema/README.md#candidate_windowdecoration可选) |
| `[candidate_window.background]` | 卡片背景图:`image`、`fit`(`cover` / `contain` / `stretch`)、`opacity`(0–1) |
| `[candidate.dark]` / `[candidate.light]` | 候选配色:`accent`、`selected`、`hover`、`surface`、`border`、`text`、`number`、`translation`、`show_selected_bar`;细分配色 `candidate_text`、`preedit_text`、`preedit_caret`、`selected_text`、`selected_number`、`selected_translation`、`translation_pos`、`translation_separator`、`selected_translation_pos`、`selected_translation_separator`、`selected_bar`、`preedit_background`、`preedit_divider`、`page_arrow_divider`(三角翻页箭头前的分隔线) |
| `[candidate.dark.menu]` / `[candidate.light.menu]` | 候选窗右键菜单:`background`、`border`、`text`、`hover` |
| `[candidate_window.border_image]` | v2 九宫格面板图:`source`、`light_source`(浅色主题换用的面板图,几何与 `source` 相同)、`slice_dip`、`stretch_dip`、`width_dip`、`fill`,见 [schema 说明](../examples/skin-examples/schema/README.md) |
| `[toolbar]` / `[toolbar.dark]` / `[toolbar.light]` | 悬浮工具栏:`corner_radius_dip`、`icon_corner_radius_dip`(图标悬停底色的圆角,0–16),以及 `background`、`border`、`handle`(不写时取同主题的 `accent`)、`divider`、`icon`、`hover` |
| `[toolbar]` / `[toolbar.dark]` / `[toolbar.light]` | 悬浮工具栏:`corner_radius_dip`、`icon_corner_radius_dip`(图标悬停底色的圆角,0–16),以及 `background`、`border`、`handle`(不写时取同主题的 `accent`)、`divider`、`icon`、`hover`;`mode = "image"` 时整个工具栏换成一张背景图(`image`、`image_width_dip`、`image_height_dip`),窗口按图片透明区域裁成异形,按钮用 `[toolbar.buttons.<按钮>]` 的 `x_dip`、`y_dip`、`width_dip`、`height_dip`、`icon_size_dip`、`visible`、`image` 与按状态的 `[images]` 摆放,见 [schema 说明](../examples/skin-examples/schema/README.md#图片模式mode--image) |
| `[license]` | `code`、`assets`、`source`:代码与素材的授权信息 |
所有颜色键(`[candidate.*]` 里除 `show_selected_bar` 外的键、`[candidate.*.menu]` 与 `[toolbar.*]` 的全部键)都支持十六进制 `#RGB`、`#RGBA`、`#RRGGBB`、`#RRGGBBAA`,以及 `rgb(r, g, b)`、`rgba(r, g, b, a)` 和 `transparent`。十六进制必须带 `#`(清单格式的约定,与 CSS 写法一致;渲染器对不带 `#` 的写法有容错,但不要依赖它),不透明度在末尾(`#RRGGBBAA`,不是 `#AARRGGBB`),例如 `rgba(224, 138, 168, 0.28)` 与 `#e08aa847` 等价。图片路径相对于皮肤目录,且不能指向目录之外。完整的带注释示例见 [`niya-demo/skin.toml`](niya-demo/skin.toml)。
+2
View File
@@ -33,6 +33,7 @@ add_library(msimeui STATIC
src/DeviceResources.cpp
src/Fonts.cpp
src/Image.cpp
src/ImageMask.cpp
src/ListView.cpp
src/Menu.cpp
src/PagerArrows.cpp
@@ -63,6 +64,7 @@ add_library(msimeui STATIC
include/msimeui/Controls.h
include/msimeui/DeviceResources.h
include/msimeui/Fonts.h
include/msimeui/ImageMask.h
include/msimeui/Layout.h
include/msimeui/Scene.h
include/msimeui/Theme.h
+20
View File
@@ -0,0 +1,20 @@
#pragma once
#include <string>
#include <vector>
#include <windows.h>
namespace msimeui
{
// The pixels of a 32-bit BGRA bitmap whose alpha is above `threshold`, as rectangles: runs of
// such pixels in a row, with identical runs in consecutive rows merged into one taller rectangle.
// Only the alpha byte is read, so premultiplied and straight alpha give the same result.
// Rectangles come out top to bottom and left to right, the order a region needs.
std::vector<RECT> AlphaMaskRects(const BYTE *pixels, UINT width, UINT height, UINT stride, BYTE threshold);
// The image at `filePath` scaled to width x height pixels, as a region of its pixels with alpha
// above `threshold`. Animated images (APNG, GIF) count a pixel when any frame covers it, since a
// window region clips drawing as well as clicks. Suits SetWindowRgn for a window shaped like the
// image. Null when the image cannot be read or has no such pixel; the caller owns the region.
HRGN CreateAlphaMaskRegion(const std::wstring &filePath, UINT width, UINT height, BYTE threshold = 0);
} // namespace msimeui
+23
View File
@@ -228,6 +228,29 @@ class WrapPanel : public Panel
std::vector<RowItem> rowItems_;
};
// Absolute placement: each child sits at the (x, y) given to AddChildAt, in DIPs from the
// panel's padded top-left corner, at its own measured size. The panel measures to the
// smallest box that holds every child. Later children draw on top and win hit tests.
class OverlayPanel : public Panel
{
public:
void AddChildAt(std::shared_ptr<Visual> child, float x, float y);
// Moves a child already in the panel; does nothing for any other visual.
void SetChildPosition(const Visual *child, float x, float y);
PointF GetChildPosition(const Visual *child) const;
SizeF Measure(const SizeF &availableSize) override;
void Arrange(const RectF &finalRect) override;
void Render(DeviceResources &deviceResources) override;
private:
// Every child needs a position, so AddChildAt is the only way in.
using Panel::AddChild;
std::vector<PointF> positions_;
std::vector<SizeF> measuredChildren_;
};
class ScrollViewer : public Visual
{
public:
+135
View File
@@ -0,0 +1,135 @@
// ImageMask: turns the alpha channel of an image into rectangles / a GDI region for shaped windows.
#include "msimeui/ImageMask.h"
#include "msimeui/AnimatedImage.h"
#include <algorithm>
#include <optional>
#include <utility>
#include <wincodec.h>
#include <wrl/client.h>
namespace msimeui
{
namespace
{
using Run = std::pair<LONG, LONG>;
void RowRuns(const BYTE *row, UINT width, BYTE threshold, std::vector<Run> &runs)
{
runs.clear();
UINT x = 0;
while (x < width)
{
while (x < width && row[x * 4 + 3] <= threshold)
{
++x;
}
const UINT start = x;
while (x < width && row[x * 4 + 3] > threshold)
{
++x;
}
if (x > start)
{
runs.emplace_back(static_cast<LONG>(start), static_cast<LONG>(x));
}
}
}
void FlushRuns(const std::vector<Run> &runs, LONG top, LONG bottom, std::vector<RECT> &rects)
{
for (const Run &run : runs)
{
rects.push_back({run.first, top, run.second, bottom});
}
}
} // namespace
std::vector<RECT> AlphaMaskRects(const BYTE *pixels, UINT width, UINT height, UINT stride, BYTE threshold)
{
std::vector<RECT> rects;
if (!pixels || width == 0 || height == 0 || stride < width * 4)
{
return rects;
}
// Runs of the rows since `groupTop`, all identical; a row with different runs closes them.
std::vector<Run> group;
std::vector<Run> runs;
LONG groupTop = 0;
for (UINT y = 0; y < height; ++y)
{
RowRuns(pixels + static_cast<size_t>(y) * stride, width, threshold, runs);
if (runs != group)
{
FlushRuns(group, groupTop, static_cast<LONG>(y), rects);
group.swap(runs);
groupTop = static_cast<LONG>(y);
}
}
FlushRuns(group, groupTop, static_cast<LONG>(height), rects);
return rects;
}
HRGN CreateAlphaMaskRegion(const std::wstring &filePath, UINT width, UINT height, BYTE threshold)
{
using Microsoft::WRL::ComPtr;
if (filePath.empty() || width == 0 || height == 0)
{
return nullptr;
}
ComPtr<IWICImagingFactory> factory;
if (FAILED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER,
IID_PPV_ARGS(factory.GetAddressOf()))))
{
return nullptr;
}
const std::optional<AnimatedImage> image = AnimatedImage::DecodeFile(factory.Get(), filePath);
if (!image || image->Width() == 0 || image->Height() == 0)
{
return nullptr;
}
// A window region clips what is drawn, not just clicks, so an animation is shaped by every
// frame at once: whatever any frame covers stays inside the window.
const UINT sourceStride = image->Width() * 4;
std::vector<BYTE> coverage(static_cast<size_t>(sourceStride) * image->Height(), 0);
for (size_t frame = 0; frame < image->FrameCount(); ++frame)
{
const std::vector<uint8_t> &pixels = image->FramePixels(frame);
for (size_t alpha = 3; alpha < coverage.size() && alpha < pixels.size(); alpha += 4)
{
coverage[alpha] = (std::max)(coverage[alpha], pixels[alpha]);
}
}
ComPtr<IWICBitmap> source;
ComPtr<IWICBitmapScaler> scaler;
if (FAILED(factory->CreateBitmapFromMemory(image->Width(), image->Height(), GUID_WICPixelFormat32bppPBGRA,
sourceStride, static_cast<UINT>(coverage.size()), coverage.data(),
source.GetAddressOf())) ||
FAILED(factory->CreateBitmapScaler(scaler.GetAddressOf())) ||
FAILED(scaler->Initialize(source.Get(), width, height, WICBitmapInterpolationModeFant)))
{
return nullptr;
}
const UINT stride = width * 4;
std::vector<BYTE> pixels(static_cast<size_t>(stride) * height);
if (FAILED(scaler->CopyPixels(nullptr, stride, static_cast<UINT>(pixels.size()), pixels.data())))
{
return nullptr;
}
const std::vector<RECT> rects = AlphaMaskRects(pixels.data(), width, height, stride, threshold);
if (rects.empty())
{
return nullptr;
}
std::vector<BYTE> buffer(sizeof(RGNDATAHEADER) + rects.size() * sizeof(RECT));
auto *data = reinterpret_cast<RGNDATA *>(buffer.data());
data->rdh.dwSize = sizeof(RGNDATAHEADER);
data->rdh.iType = RDH_RECTANGLES;
data->rdh.nCount = static_cast<DWORD>(rects.size());
data->rdh.nRgnSize = static_cast<DWORD>(rects.size() * sizeof(RECT));
data->rdh.rcBound = {0, 0, static_cast<LONG>(width), static_cast<LONG>(height)};
std::copy(rects.begin(), rects.end(), reinterpret_cast<RECT *>(data->Buffer));
return ExtCreateRegion(nullptr, static_cast<DWORD>(buffer.size()), data);
}
} // namespace msimeui
+74
View File
@@ -1160,6 +1160,80 @@ void WrapPanel::Render(DeviceResources &deviceResources)
}
}
void OverlayPanel::AddChildAt(std::shared_ptr<Visual> child, float x, float y)
{
if (!child)
{
return;
}
Panel::AddChild(std::move(child));
positions_.push_back({x, y});
}
void OverlayPanel::SetChildPosition(const Visual *child, float x, float y)
{
for (size_t index = 0; index < children_.size(); ++index)
{
if (children_[index].get() == child)
{
positions_[index] = {x, y};
InvalidateMeasure();
return;
}
}
}
PointF OverlayPanel::GetChildPosition(const Visual *child) const
{
for (size_t index = 0; index < children_.size(); ++index)
{
if (children_[index].get() == child)
{
return positions_[index];
}
}
return {};
}
SizeF OverlayPanel::Measure(const SizeF &availableSize)
{
measuredChildren_.clear();
const SizeF innerAvailable = DeflateSize(availableSize, padding_);
float width = 0.0f;
float height = 0.0f;
for (size_t index = 0; index < children_.size(); ++index)
{
const SizeF measured = children_[index]->MeasureInLayout(innerAvailable);
measuredChildren_.push_back(measured);
width = std::max(width, positions_[index].x + measured.width);
height = std::max(height, positions_[index].y + measured.height);
}
return {std::min(width + padding_.left + padding_.right, availableSize.width),
std::min(height + padding_.top + padding_.bottom, availableSize.height)};
}
void OverlayPanel::Arrange(const RectF &finalRect)
{
bounds_ = finalRect;
const RectF inner = DeflateRect(finalRect, padding_);
for (size_t index = 0; index < children_.size() && index < measuredChildren_.size(); ++index)
{
children_[index]->ArrangeInLayout({inner.x + positions_[index].x, inner.y + positions_[index].y,
measuredChildren_[index].width, measuredChildren_[index].height});
}
}
void OverlayPanel::Render(DeviceResources &deviceResources)
{
for (const auto &child : children_)
{
if (ShouldRenderChild(deviceResources, *child))
{
child->Render(deviceResources);
}
}
}
ScrollViewer::ScrollViewer(std::shared_ptr<Visual> content) : content_(std::move(content))
{
AdoptChild(content_);
+1
View File
@@ -9,6 +9,7 @@ add_executable(${TEST_EXECUTABLE_NAME}
src/test_fonts.cpp
src/test_image.cpp
src/test_layout.cpp
src/test_overlay_panel.cpp
src/test_font_fallback.cpp
src/test_menu_flyout_item.cpp
src/test_pager_arrows.cpp
+147
View File
@@ -0,0 +1,147 @@
#include "tests/includes/test_framework.h"
#include "msimeui/ImageMask.h"
#include "msimeui/Layout.h"
#include <memory>
#include <vector>
using namespace msimeui;
namespace
{
// Fixed-size leaf for observing how OverlayPanel measures and places children.
class FixedBox : public Visual
{
public:
explicit FixedBox(SizeF size) : size_(size)
{
}
SizeF Measure(const SizeF &) override
{
return size_;
}
void Arrange(const RectF &finalRect) override
{
bounds_ = finalRect;
}
void Render(DeviceResources &) override
{
}
private:
SizeF size_;
};
// A BGRA bitmap whose alpha is 255 where `mask` has '#', 0 elsewhere.
std::vector<BYTE> MaskPixels(const std::vector<const char *> &mask, UINT width)
{
std::vector<BYTE> pixels(static_cast<size_t>(width) * 4 * mask.size(), 0);
for (size_t y = 0; y < mask.size(); ++y)
{
for (UINT x = 0; x < width; ++x)
{
pixels[(y * width + x) * 4 + 3] = mask[y][x] == '#' ? 255 : 0;
}
}
return pixels;
}
bool SameRect(const RECT &rect, LONG left, LONG top, LONG right, LONG bottom)
{
return rect.left == left && rect.top == top && rect.right == right && rect.bottom == bottom;
}
} // namespace
TEST_CASE(overlay_panel_places_children_at_their_positions)
{
auto panel = std::make_shared<OverlayPanel>();
auto background = std::make_shared<FixedBox>(SizeF{100.0f, 50.0f});
auto icon = std::make_shared<FixedBox>(SizeF{20.0f, 20.0f});
panel->AddChildAt(background, 0.0f, 0.0f);
panel->AddChildAt(icon, 90.0f, 40.0f);
const SizeF measured = panel->MeasureInLayout({1000.0f, 1000.0f});
REQUIRE_NEAR(measured.width, 110.0f);
REQUIRE_NEAR(measured.height, 60.0f);
panel->ArrangeInLayout({10.0f, 5.0f, measured.width, measured.height});
const RectF backgroundBounds = background->GetBounds();
REQUIRE_NEAR(backgroundBounds.x, 10.0f);
REQUIRE_NEAR(backgroundBounds.y, 5.0f);
REQUIRE_NEAR(backgroundBounds.width, 100.0f);
REQUIRE_NEAR(backgroundBounds.height, 50.0f);
const RectF iconBounds = icon->GetBounds();
REQUIRE_NEAR(iconBounds.x, 100.0f);
REQUIRE_NEAR(iconBounds.y, 45.0f);
REQUIRE_NEAR(iconBounds.width, 20.0f);
REQUIRE_NEAR(iconBounds.height, 20.0f);
}
TEST_CASE(overlay_panel_moves_a_child_it_holds)
{
auto panel = std::make_shared<OverlayPanel>();
auto child = std::make_shared<FixedBox>(SizeF{10.0f, 10.0f});
auto stranger = std::make_shared<FixedBox>(SizeF{10.0f, 10.0f});
panel->AddChildAt(child, 0.0f, 0.0f);
panel->SetChildPosition(child.get(), 5.0f, 6.0f);
// A visual outside the panel is ignored rather than moving some other child.
panel->SetChildPosition(stranger.get(), 50.0f, 60.0f);
const SizeF measured = panel->MeasureInLayout({100.0f, 100.0f});
panel->ArrangeInLayout({0.0f, 0.0f, measured.width, measured.height});
REQUIRE_NEAR(child->GetBounds().x, 5.0f);
REQUIRE_NEAR(child->GetBounds().y, 6.0f);
REQUIRE_NEAR(panel->GetChildPosition(child.get()).x, 5.0f);
REQUIRE_NEAR(measured.width, 15.0f);
}
TEST_CASE(overlay_panel_hit_test_prefers_later_child)
{
auto panel = std::make_shared<OverlayPanel>();
auto background = std::make_shared<FixedBox>(SizeF{100.0f, 100.0f});
auto icon = std::make_shared<FixedBox>(SizeF{10.0f, 10.0f});
panel->AddChildAt(background, 0.0f, 0.0f);
panel->AddChildAt(icon, 50.0f, 50.0f);
const SizeF measured = panel->MeasureInLayout({1000.0f, 1000.0f});
panel->ArrangeInLayout({0.0f, 0.0f, measured.width, measured.height});
REQUIRE(panel->FindVisualAt({55.0f, 55.0f}) == icon.get());
REQUIRE(panel->FindVisualAt({5.0f, 5.0f}) == background.get());
}
TEST_CASE(alpha_mask_rects_merge_identical_rows)
{
const UINT width = 6;
const std::vector<BYTE> pixels = MaskPixels({"##..##", //
"##..##", //
"######", //
"......"},
width);
const std::vector<RECT> rects = AlphaMaskRects(pixels.data(), width, 4, width * 4, 0);
REQUIRE(rects.size() == 3);
REQUIRE(SameRect(rects[0], 0, 0, 2, 2));
REQUIRE(SameRect(rects[1], 4, 0, 6, 2));
REQUIRE(SameRect(rects[2], 0, 2, 6, 3));
}
TEST_CASE(alpha_mask_rects_respect_threshold_and_empty_images)
{
const UINT width = 3;
std::vector<BYTE> pixels = MaskPixels({"###"}, width);
// Semi-transparent edge pixel: inside at threshold 0, outside at 128.
pixels[3] = 100;
REQUIRE(AlphaMaskRects(pixels.data(), width, 1, width * 4, 0).size() == 1);
const std::vector<RECT> strict = AlphaMaskRects(pixels.data(), width, 1, width * 4, 128);
REQUIRE(strict.size() == 1);
REQUIRE(SameRect(strict[0], 1, 0, 3, 1));
const std::vector<BYTE> clear = MaskPixels({"...", "..."}, width);
REQUIRE(AlphaMaskRects(clear.data(), width, 2, width * 4, 0).empty());
REQUIRE(AlphaMaskRects(nullptr, width, 2, width * 4, 0).empty());
}