diff --git a/examples/skin-examples/schema/README.md b/examples/skin-examples/schema/README.md index 125ee522..b432eb39 100644 --- a/examples/skin-examples/schema/README.md +++ b/examples/skin-examples/schema/README.md @@ -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`,不会让皮肤读不出来,只在设置页的诊断信息里提示一条。 ## 颜色格式 diff --git a/examples/skin-examples/tests/test_manifests.py b/examples/skin-examples/tests/test_manifests.py index 3c1346fb..bff78bf2 100644 --- a/examples/skin-examples/tests/test_manifests.py +++ b/examples/skin-examples/tests/test_manifests.py @@ -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 diff --git a/server/src/settings/native/native_page_skin_common.cpp b/server/src/settings/native/native_page_skin_common.cpp index c52c9bba..5064f477 100644 --- a/server/src/settings/native/native_page_skin_common.cpp +++ b/server/src/settings/native/native_page_skin_common.cpp @@ -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(package->toolbarImageWidthDip), + static_cast(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(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(*placement.xDip), static_cast(*placement.yDip), + static_cast(placement.widthDip.value_or(CandidateSkinCatalog::kDefaultToolbarButtonSizeDip)), + static_cast(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(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 items; + std::vector words; const size_t count = std::min(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(); diff --git a/server/src/settings/native/native_page_skin_common.h b/server/src/settings/native/native_page_skin_common.h index ba29d63e..7454623f 100644 --- a/server/src/settings/native/native_page_skin_common.h +++ b/server/src/settings/native/native_page_skin_common.h @@ -76,6 +76,26 @@ struct ToolbarLook float x, y, blur, alpha; }; std::vector 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 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_; diff --git a/server/src/settings/native/native_page_skin_widgets.cpp b/server/src/settings/native/native_page_skin_widgets.cpp index 020f2828..6b81f577 100644 --- a/server/src/settings/native/native_page_skin_widgets.cpp +++ b/server/src/settings/native/native_page_skin_widgets.cpp @@ -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 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 // --------------------------------------------------------------------------------- diff --git a/server/src/skin/candidate_skin_catalog.cpp b/server/src/skin/candidate_skin_catalog.cpp index f16fd343..b0ba12cf 100644 --- a/server/src/skin/candidate_skin_catalog.cpp +++ b/server/src/skin/candidate_skin_catalog.cpp @@ -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 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 fontSize; + std::optional minFontSize; + std::optional 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 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(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.] 的一项。未知的状态名记一条提示后忽略,不让整份皮肤读不出来。 +bool ReadToolbarButton(const toml::node &node, const std::string &key, const std::vector &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.]。 +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 width; + std::optional 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 *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 &out) { @@ -781,6 +1018,26 @@ bool IsAnimatableImagePath(const std::string &path) return extension == ".png" || extension == ".apng" || extension == ".gif" || extension == ".webp"; } +const std::vector *ToolbarButtonStates(const std::string &key) +{ + static const std::vector status = {"cn", "en", "ja", "caps", "en-candidate"}; + static const std::vector fullwidth = {"full", "half"}; + static const std::vector punctuation = {"cn", "en"}; + static const std::vector characterSet = {"simplified", "traditional"}; + static const std::vector 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 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 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 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); diff --git a/server/src/skin/candidate_skin_catalog.h b/server/src/skin/candidate_skin_catalog.h index 307286d4..2ed2b612 100644 --- a/server/src/skin/candidate_skin_catalog.h +++ b/server/src/skin/candidate_skin_catalog.h @@ -2,6 +2,7 @@ #include #include +#include #include #include #include @@ -65,6 +66,47 @@ struct ToolbarColors std::string hover; }; +// 图片模式([toolbar] mode = "image")下一个按钮的摆法与贴图:[toolbar.buttons.]。 +struct ToolbarButton +{ + // false 时皮肤把它藏起来;皮肤不能让用户在设置页关掉的按钮重新出现。 + std::optional visible; + // 按钮框左上角相对背景图左上角的位置(DIP)。两个都写了按钮才会出现,没写坐标的按钮不画。 + std::optional xDip; + std::optional yDip; + // 按钮框的宽高(DIP);没写时为 kDefaultToolbarButtonSizeDip。 + std::optional widthDip; + std::optional heightDip; + // 没有贴图时画的内置图标(Segoe Fluent Icons)的字号(DIP);没写时为按钮框短边的 0.82 倍。 + std::optional iconSizeDip; + // 各状态共用的图;images 里没有当前状态的图时也用它。两者都没有就画内置的矢量图标。 + std::string image; + // 状态名 → 图,只对有状态的按钮有效,状态名见 ToolbarButtonStates。 + std::map 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; // 候选框里的翻页箭头;未设置时沿用 base 默认皮肤的设置(见 ResolvePageArrows)。 std::optional pageArrows; // 卡片严格按候选文字所需的宽度收紧,不再垫出厂最小宽度、也不在同一输入里只增不减; @@ -216,6 +260,15 @@ struct Package std::optional 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 toolbarButtons; // v2 专有特性:九宫格贴图边框 std::optional 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 *ToolbarButtonStates(const std::string &key); + // 指定主题下实际要画的面板图:浅色主题优先 light_source,没写就用 source。 const std::string &BorderImageSource(const BorderImage &borderImage, bool light); diff --git a/server/src/utils/window_utils.cpp b/server/src/utils/window_utils.cpp index 8bcabaf3..d54da623 100644 --- a/server/src/utils/window_utils.cpp +++ b/server/src/utils/window_utils.cpp @@ -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 (hasRegion && kind != NULLREGION) - { - SetWindowRgn(hwnd, nullptr, TRUE); - } - } - else + if (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); + } + } // 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 diff --git a/server/src/utils/window_utils.h b/server/src/utils/window_utils.h index df3d434f..dca202d1 100644 --- a/server/src/utils/window_utils.h +++ b/server/src/utils/window_utils.h @@ -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. diff --git a/server/src/window/candidate_presenter.cpp b/server/src/window/candidate_presenter.cpp index e687158b..7f00ab1d 100644 --- a/server/src/window/candidate_presenter.cpp +++ b/server/src/window/candidate_presenter.cpp @@ -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 items; items.reserve(page->page_views.size()); + std::vector 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((std::max)(0, page->selected_index_in_page))); ignoreSelectionCallback_ = false; + impl_->scene.SetDecorationCandidates(words, static_cast((std::max)(0, page->selected_index_in_page))); if (impl_->pager) { impl_->pager->SetEnabled(page->has_previous_page, page->has_next_page); diff --git a/server/src/window/candidate_skin_scene.cpp b/server/src/window/candidate_skin_scene.cpp index 81c14dee..3b105de4 100644 --- a/server/src/window/candidate_skin_scene.cpp +++ b/server/src/window/candidate_skin_scene.cpp @@ -412,8 +412,14 @@ void CandidateSkinScene::Build(bool horizontal, bool pageArrows, bool expandPane expandPanel_ = expandPanel; root_ = std::make_shared(0.0f); decoration_ = std::make_shared(L""); - decoration_->SetHorizontalAlignment(msimeui::HorizontalAlignment::Trailing); decoration_->SetStretch(msimeui::ImageStretch::Uniform); + decorationWord_ = std::make_shared(L"", 16.0f, D2D1::ColorF(D2D1::ColorF::White)); + decorationWord_->SetTextLayoutPadding({0.0f, 0.0f, 0.0f, 0.0f}); + decorationBox_ = std::make_shared(); + 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(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(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(); 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(package->decorationWidthDip); + decorationBoxHeightDip_ = decorationTopDip; decoration_->SetSource(decorationPath); - decoration_->SetHeight(decorationTopDip); - decoration_->SetWidth(static_cast(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 &page, size_t selectedIndex) +{ + if (!decorationWord_) + return; + std::wstring word; + if (decorationCandidate_) + { + const size_t index = + decorationCandidate_->index == 0 ? selectedIndex : static_cast(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(style.fontSizeDip); + decorationWord_->ClearWidth(); + decorationWord_->ClearHeight(); + decorationWord_->SetFontSize(fontSize); + msimeui::SizeF size = decorationWord_->MeasureInLayout(unbounded); + const float limit = style.maxWidthDip > 0.0 ? static_cast(style.maxWidthDip) : decorationBoxWidthDip_; + const float minFontSize = (std::min)(fontSize, static_cast(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(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(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); +} diff --git a/server/src/window/candidate_skin_scene.h b/server/src/window/candidate_skin_scene.h index 9b3e9cea..65e05c3d 100644 --- a/server/src/window/candidate_skin_scene.h +++ b/server/src/window/candidate_skin_scene.h @@ -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 &page, size_t selectedIndex); // 场景的根(外框所在的竖排面板);第一次 Apply 之前为空。 const std::shared_ptr &Root() const @@ -231,7 +241,16 @@ class CandidateSkinScene bool pageArrows_ = false; bool expandPanel_ = false; std::shared_ptr root_; + // 装饰框:装饰图铺满,叠加的候选词按皮肤的对齐与偏移摆在上面。 + std::shared_ptr decorationBox_; std::shared_ptr decoration_; + std::shared_ptr decorationWord_; + // 当前皮肤的叠加设置;皮肤没装饰图或没开 candidate_overlay 时为空。 + std::optional decorationCandidate_; + float decorationBoxWidthDip_ = 0.0f; + float decorationBoxHeightDip_ = 0.0f; + // 正画着的词:同一个词不再重新量字号。 + std::wstring decorationWordShown_; std::shared_ptr card_; // 卡片与 v2 挂件层放在同一格里:挂件层测量为 0,格子仍由卡片决定,挂件按 anchor 画到卡片外沿。 // 网格按子元素顺序绘制:下层挂件、卡片、上层挂件。 diff --git a/server/src/window/floating_toolbar_presenter.cpp b/server/src/window/floating_toolbar_presenter.cpp index 5e329f16..e8117e32 100644 --- a/server/src/window/floating_toolbar_presenter.cpp +++ b/server/src/window/floating_toolbar_presenter.cpp @@ -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 +#include #include #include #include #include #include +#include #include #include +#include #include #include @@ -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; - 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(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 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 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(resolved.codepoint, resolved.family, L"", width, height); + } + } + auto button = std::make_shared(0, L"", spec.text, width, height); + button->SetUnderline(spec.underline); + return button; +} } // namespace struct FloatingToolbarPresenter::Impl { msimeui::DeviceResources resources; std::unique_ptr window; - std::shared_ptr root; + std::shared_ptr root; std::shared_ptr card; std::shared_ptr frame; std::shared_ptr 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 buttons; + // 图片模式下摆出来的按钮:落在它们上面的点不算拖动区。 + std::vector> 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 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(); + const float width = impl_->imageWidthDip * s; + const float height = impl_->imageHeightDip * s; + root->SetWidth(width); + root->SetHeight(height); + auto background = std::make_shared(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 value) { + return static_cast(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(*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(*placement.xDip) * s, static_cast(*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(); + scene->SetRoot(impl_->root); + impl_->window->SetScene(std::move(scene)); + return; + } + auto row = std::make_shared(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(gap); icons->SetVerticalContentAlignment(msimeui::VerticalAlignment::Center); - auto addText = [&](std::wstring text, bool underline, ToolbarIconButton::ClickHandler click) { - auto button = std::make_shared(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(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(0.0f); - impl_->root->AddChild(impl_->frame); + auto root = std::make_shared(0.0f); + root->AddChild(impl_->frame); + impl_->root = root; auto scene = std::make_unique(); 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(package->toolbarImageWidthDip); + impl_->imageHeightDip = static_cast(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(std::lround(measured.width * scale)), + static_cast(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(imageWidthPx), + static_cast(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(std::floor(box.x * scale)), static_cast(std::floor(box.y * scale)), + static_cast(std::ceil((box.x + box.width) * scale)), + static_cast(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(*deadline - msimeui::DeviceResources::AnimationClock::now()); + const UINT delay = static_cast((std::clamp)(wait.count(), static_cast(USER_TIMER_MINIMUM), + static_cast(USER_TIMER_MAXIMUM))); + // One shot per frame, replacing any pending one; 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); diff --git a/server/src/window/floating_toolbar_presenter.h b/server/src/window/floating_toolbar_presenter.h index b20a8d77..51264f36 100644 --- a/server/src/window/floating_toolbar_presenter.h +++ b/server/src/window/floating_toolbar_presenter.h @@ -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_; diff --git a/server/src/window/ime_windows_toolbar.cpp b/server/src/window/ime_windows_toolbar.cpp index 087834c4..1147cc27 100644 --- a/server/src/window/ime_windows_toolbar.cpp +++ b/server/src/window/ime_windows_toolbar.cpp @@ -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(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); } diff --git a/server/tests/CMakeLists.txt b/server/tests/CMakeLists.txt index 85c7c181..b81d7d6b 100644 --- a/server/tests/CMakeLists.txt +++ b/server/tests/CMakeLists.txt @@ -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" diff --git a/server/tests/src/test_candidate_skin_catalog_toolbar_image.cpp b/server/tests/src/test_candidate_skin_catalog_toolbar_image.cpp new file mode 100644 index 00000000..d806dde6 --- /dev/null +++ b/server/tests/src/test_candidate_skin_catalog_toolbar_image.cpp @@ -0,0 +1,289 @@ +#include "tests/includes/test_framework.h" + +#include "skin/candidate_skin_catalog.h" + +#include + +#include +#include +#include +#include +#include +#include + +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 ¬ice) { 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")); +} diff --git a/skins/README.md b/skins/README.md index 8f270551..95194a7b 100644 --- a/skins/README.md +++ b/skins/README.md @@ -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)。 diff --git a/ui/CMakeLists.txt b/ui/CMakeLists.txt index 8f5369f1..d9bba67f 100644 --- a/ui/CMakeLists.txt +++ b/ui/CMakeLists.txt @@ -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 diff --git a/ui/include/msimeui/ImageMask.h b/ui/include/msimeui/ImageMask.h new file mode 100644 index 00000000..9159ed0b --- /dev/null +++ b/ui/include/msimeui/ImageMask.h @@ -0,0 +1,20 @@ +#pragma once + +#include +#include +#include + +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 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 diff --git a/ui/include/msimeui/Layout.h b/ui/include/msimeui/Layout.h index 7c8c6953..ebe3863f 100644 --- a/ui/include/msimeui/Layout.h +++ b/ui/include/msimeui/Layout.h @@ -228,6 +228,29 @@ class WrapPanel : public Panel std::vector 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 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 positions_; + std::vector measuredChildren_; +}; + class ScrollViewer : public Visual { public: diff --git a/ui/src/ImageMask.cpp b/ui/src/ImageMask.cpp new file mode 100644 index 00000000..b0bca1e8 --- /dev/null +++ b/ui/src/ImageMask.cpp @@ -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 +#include +#include +#include +#include + +namespace msimeui +{ +namespace +{ +using Run = std::pair; + +void RowRuns(const BYTE *row, UINT width, BYTE threshold, std::vector &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(start), static_cast(x)); + } + } +} + +void FlushRuns(const std::vector &runs, LONG top, LONG bottom, std::vector &rects) +{ + for (const Run &run : runs) + { + rects.push_back({run.first, top, run.second, bottom}); + } +} +} // namespace + +std::vector AlphaMaskRects(const BYTE *pixels, UINT width, UINT height, UINT stride, BYTE threshold) +{ + std::vector 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 group; + std::vector runs; + LONG groupTop = 0; + for (UINT y = 0; y < height; ++y) + { + RowRuns(pixels + static_cast(y) * stride, width, threshold, runs); + if (runs != group) + { + FlushRuns(group, groupTop, static_cast(y), rects); + group.swap(runs); + groupTop = static_cast(y); + } + } + FlushRuns(group, groupTop, static_cast(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 factory; + if (FAILED(CoCreateInstance(CLSID_WICImagingFactory, nullptr, CLSCTX_INPROC_SERVER, + IID_PPV_ARGS(factory.GetAddressOf())))) + { + return nullptr; + } + const std::optional 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 coverage(static_cast(sourceStride) * image->Height(), 0); + for (size_t frame = 0; frame < image->FrameCount(); ++frame) + { + const std::vector &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 source; + ComPtr scaler; + if (FAILED(factory->CreateBitmapFromMemory(image->Width(), image->Height(), GUID_WICPixelFormat32bppPBGRA, + sourceStride, static_cast(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 pixels(static_cast(stride) * height); + if (FAILED(scaler->CopyPixels(nullptr, stride, static_cast(pixels.size()), pixels.data()))) + { + return nullptr; + } + const std::vector rects = AlphaMaskRects(pixels.data(), width, height, stride, threshold); + if (rects.empty()) + { + return nullptr; + } + std::vector buffer(sizeof(RGNDATAHEADER) + rects.size() * sizeof(RECT)); + auto *data = reinterpret_cast(buffer.data()); + data->rdh.dwSize = sizeof(RGNDATAHEADER); + data->rdh.iType = RDH_RECTANGLES; + data->rdh.nCount = static_cast(rects.size()); + data->rdh.nRgnSize = static_cast(rects.size() * sizeof(RECT)); + data->rdh.rcBound = {0, 0, static_cast(width), static_cast(height)}; + std::copy(rects.begin(), rects.end(), reinterpret_cast(data->Buffer)); + return ExtCreateRegion(nullptr, static_cast(buffer.size()), data); +} +} // namespace msimeui diff --git a/ui/src/Layout.cpp b/ui/src/Layout.cpp index b43530cc..144750c2 100644 --- a/ui/src/Layout.cpp +++ b/ui/src/Layout.cpp @@ -1160,6 +1160,80 @@ void WrapPanel::Render(DeviceResources &deviceResources) } } +void OverlayPanel::AddChildAt(std::shared_ptr 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 content) : content_(std::move(content)) { AdoptChild(content_); diff --git a/ui/tests/CMakeLists.txt b/ui/tests/CMakeLists.txt index d7cfc78f..fa8742cd 100644 --- a/ui/tests/CMakeLists.txt +++ b/ui/tests/CMakeLists.txt @@ -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 diff --git a/ui/tests/src/test_overlay_panel.cpp b/ui/tests/src/test_overlay_panel.cpp new file mode 100644 index 00000000..26e7ed12 --- /dev/null +++ b/ui/tests/src/test_overlay_panel.cpp @@ -0,0 +1,147 @@ +#include "tests/includes/test_framework.h" + +#include "msimeui/ImageMask.h" +#include "msimeui/Layout.h" + +#include +#include + +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 MaskPixels(const std::vector &mask, UINT width) +{ + std::vector pixels(static_cast(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(); + auto background = std::make_shared(SizeF{100.0f, 50.0f}); + auto icon = std::make_shared(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(); + auto child = std::make_shared(SizeF{10.0f, 10.0f}); + auto stranger = std::make_shared(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(); + auto background = std::make_shared(SizeF{100.0f, 100.0f}); + auto icon = std::make_shared(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 pixels = MaskPixels({"##..##", // + "##..##", // + "######", // + "......"}, + width); + const std::vector 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 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 strict = AlphaMaskRects(pixels.data(), width, 1, width * 4, 128); + REQUIRE(strict.size() == 1); + REQUIRE(SameRect(strict[0], 1, 0, 3, 1)); + + const std::vector clear = MaskPixels({"...", "..."}, width); + REQUIRE(AlphaMaskRects(clear.data(), width, 2, width * 4, 0).empty()); + REQUIRE(AlphaMaskRects(nullptr, width, 2, width * 4, 0).empty()); +}