From bcbbdcf74cbaa164c5358d2ec7928e239050a6c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=82=E5=B4=8E=20=E3=82=AE=E3=83=A3=E3=83=A9=E3=82=AF?= =?UTF-8?q?=E3=82=B7=E3=83=BC?= Date: Tue, 13 Oct 2026 00:57:14 +0800 Subject: [PATCH] Merge pull request #903 from Tokisaki-Galaxy/feat/skin-schema-v2 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit feat(server): 外部皮肤新增 schema_version 2,WebView2 与 D2D 双后端支持九宫格贴图与自由挂件 --- examples/skin-examples/schema/README.md | 42 +++- .../skin-examples/tests/test_manifests.py | 7 +- server/CMakeLists.txt | 2 + server/docs/ui-backends.md | 11 + server/src/skin/candidate_skin_catalog.cpp | 179 ++++++++++++++++- server/src/skin/candidate_skin_catalog.h | 47 +++++ server/src/webview2/windows_webview2.h | 5 + server/src/webview2/windows_webview2_skin.cpp | 190 +++++++++++++++++- server/src/window/candidate_presenter.cpp | 102 +++++++++- server/src/window/candidate_skin_overlay.cpp | 102 ++++++++++ server/src/window/candidate_skin_overlay.h | 40 ++++ .../window/ime_windows_candidate_layout.cpp | 45 ++++- .../src/window/ime_windows_candidate_show.cpp | 25 ++- server/src/window/ime_windows_internal.h | 3 + .../test_candidate_skin_catalog_images.cpp | 137 +++++++++++++ ui/include/msimeui/Layout.h | 14 +- ui/src/Layout.cpp | 97 ++++++++- 17 files changed, 1012 insertions(+), 36 deletions(-) create mode 100644 server/src/window/candidate_skin_overlay.cpp create mode 100644 server/src/window/candidate_skin_overlay.h diff --git a/examples/skin-examples/schema/README.md b/examples/skin-examples/schema/README.md index 99b5af71..dc56490a 100644 --- a/examples/skin-examples/schema/README.md +++ b/examples/skin-examples/schema/README.md @@ -1,12 +1,12 @@ # 外部皮肤 manifest(TOML) -每个皮肤目录需要 `skin.toml`,`id` 必须与文件夹名一致。`schema_version` 当前为 `1`。 +每个皮肤目录需要 `skin.toml`,`id` 必须与文件夹名一致。`schema_version` 支持 `1` 与 `2`(v2 为面向全贴图与复杂立绘皮肤的高级规范,仅在 WebView2 端全面启用,Direct2D 原生端保底降级)。 候选框和悬浮工具栏都由 `skin.toml` 声明,不要再提供 `cand.css` 或工具栏 CSS。所有图片路径都相对于皮肤目录,且不能指向目录之外。 ## 顶层字段 -- 必填:`schema_version`、`id`、`name`、`version`、`base`(继承的内置皮肤:`fluent`、`wechat`、`graphite`、`willow_green`、`autumn_osmanthus`、`microsoft`) +- 必填:`schema_version`(`1` 或 `2`)、`id`、`name`、`version`、`base`(继承的内置皮肤:`fluent`、`wechat`、`graphite`、`willow_green`、`autumn_osmanthus`、`microsoft`) - 可选:`author`、`description` - `[supports]`:`layouts`(`horizontal` / `vertical`)、`themes`(`dark` / `light`) - `[license]`:`code`、`assets` @@ -38,6 +38,44 @@ - `fit`:`cover`(铺满裁切)| `contain`(完整显示)| `stretch`(拉伸) - `opacity`:`0`–`1` +### `[candidate_window.border_image]`(v2 可选,仅 WebView2) + +九宫格背景拉伸,面板整体贴图,用于全贴图与复杂纹理面板。 + +- `source`:图片路径(如 `assets/skin1.png`,支持 APNG/WebP/PNG) +- `slice_dip`:`[top, right, bottom, left]` 切片线位置,正数数组,支持 1~4 个值 +- `stretch_dip`:可选,可拉伸区,语义同 `slice_dip` 但更直白,给出时覆盖它。**面板图里画死的装饰必须落在可拉伸区之外**,否则会被横向拉长;例如帆船画在右侧,就把右侧切片放到船身左侧 +- `width_dip`:可选,边框绘制宽度,支持 1~4 个值 +- `fill`:可选,布尔值,是否保留九宫格中心内容区域,默认为 `true` + +### `[candidate_window.content_insets_dip]`(v2 可选,仅 WebView2) + +候选框内容与文字的安全内边距,**从面板外缘量起**,用来避开贴图四周自带的花纹或立绘。渲染时会把该方向的边框宽度扣掉,落到内容盒上。 + +- `top` / `right` / `bottom` / `left`:四边内边距浮点数 + +### `[candidate_window]` 的 `line_gap_dip`(v2 可选) + +预编辑行与候选行之间的行距,`0`–`100`。不写则沿用 `base` 的行距。 + +### `[candidate_window.outer_insets_dip]`(v2 可选) + +挂件挂在卡片外沿时,窗口需要额外预留的透明空白,从卡片四边往外量。 + +- `top` / `right` / `bottom` / `left`:四边预留量浮点数 + +**不声明时窗口只按卡片大小开,挂到卡片外面的立绘会被窗口边界裁掉。** + +### `[[candidate_window.decorations]]`(v2 可选,数组表,仅 WebView2) + +自由锚定立绘挂件层,支持叠加角色立绘与 APNG 动图,层数不限。 + +- `image`:图片路径 +- `anchor`:`top-left` | `top-right` | `bottom-left` | `bottom-right` | `left` | `right` | `top` | `bottom` | `center` +- `width_dip` / `height_dip`:挂件的尺寸 +- `offset_x_dip` / `offset_y_dip`:相对锚点的平移量,正数向右、向下,两个后端同一套语义 +- `z_index`:层级,默认为 `2` + ## `[candidate.dark]` / `[candidate.light]` `accent`、`selected`、`hover`、`surface`、`border`、`text`、`number`、`translation`、`show_selected_bar` diff --git a/examples/skin-examples/tests/test_manifests.py b/examples/skin-examples/tests/test_manifests.py index d5bc8d13..bfb72283 100644 --- a/examples/skin-examples/tests/test_manifests.py +++ b/examples/skin-examples/tests/test_manifests.py @@ -18,7 +18,7 @@ class ManifestTests(unittest.TestCase): for path in manifests: with self.subTest(skin=path.parent.name): data = tomllib.loads(path.read_text(encoding="utf-8")) - self.assertEqual(data["schema_version"], 1) + self.assertIn(data["schema_version"], (1, 2)) self.assertEqual(data["id"], path.parent.name) self.assertRegex(data["id"], r"^[a-z0-9._-]{1,64}$") # Mirrors the Server catalog: name, version and base are required, author is not. @@ -30,6 +30,11 @@ class ManifestTests(unittest.TestCase): for table in ("decoration", "background"): if table in window: assets[f"candidate_window.{table}.image"] = window[table]["image"] + if "border_image" in window: + assets["candidate_window.border_image.source"] = window["border_image"]["source"] + for i, dec in enumerate(window.get("decorations", [])): + if "image" in dec: + assets[f"candidate_window.decorations[{i}].image"] = dec["image"] if "decoration" in window: for key in ("top_inset_dip", "width_dip"): self.assertGreater(window["decoration"][key], 0, key) diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index ec21c04f..7c7fb32c 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -269,6 +269,8 @@ set(SRC_FILES "./src/webview2/windows_webview2.h" "./src/window/candidate_presenter.cpp" "./src/window/candidate_presenter.h" + "./src/window/candidate_skin_overlay.cpp" + "./src/window/candidate_skin_overlay.h" "./src/window/candidate_skin_palette.cpp" "./src/window/candidate_skin_palette.h" "./src/window/tray_menu_presenter.cpp" diff --git a/server/docs/ui-backends.md b/server/docs/ui-backends.md index faf0279a..c8232fae 100644 --- a/server/docs/ui-backends.md +++ b/server/docs/ui-backends.md @@ -30,3 +30,14 @@ Sciter is not an active product backend. The old unreferenced handwritten D2D prototype is removed; native small windows use msimeui presenters. The msimeui library owns generic controls and rendering, never Server configuration, pipe sessions, dictionary access, or global input state. + +## v2 皮肤在两个后端下的渲染 + +`schema_version = 2` 的皮肤(九宫格贴图面板、自由锚定挂件、面板外空白)两个后端都支持,但走的是不同的实现: + +- WebView2 把面板翻成 CSS:`border-image` 铺九宫格,`content_insets_dip` 落成容器内边距,挂件用 `.containerParent` 的伪元素绝对定位。 +- D2D 用 `CandidatePresenter` 的本地场景:面板由 `msimeui::Card::SetBackgroundImageNineSlice` 画成九宫格,挂件由 `CandidateSkinOverlay` 这一层浮在卡片上,`outer_insets_dip` 换成窗口在卡片四边多留的空白。 + +两侧读同一份 `skin.toml`,字段语义必须一致,尤其是 `anchor` 与 `offset_*_dip`:正数向右、向下。改一侧的定位规则要同时改另一侧。 + +`content_insets_dip` 在两个后端都从面板外缘量起,D2D 侧由卡片内边距承担,不需要再扣边框。 diff --git a/server/src/skin/candidate_skin_catalog.cpp b/server/src/skin/candidate_skin_catalog.cpp index ccee07df..475455e5 100644 --- a/server/src/skin/candidate_skin_catalog.cpp +++ b/server/src/skin/candidate_skin_catalog.cpp @@ -330,12 +330,14 @@ std::optional Load(const std::filesystem::path &skinsRoot, const std::s return std::nullopt; } const toml::table &root = *parsed; - if (root["schema_version"].value_or(0) != 1) + const int schemaVersion = static_cast(root["schema_version"].value_or(0)); + if (schemaVersion != 1 && schemaVersion != 2) { - SetError(error, "仅支持 schema_version 1"); + SetError(error, "仅支持 schema_version 1 或 2"); return std::nullopt; } Package package; + package.schemaVersion = schemaVersion; if (!ReadString(root, "id", package.id, 64, true) || package.id != id || !ReadString(root, "name", package.name, 80, true) || !ReadString(root, "version", package.version, 32, true) || @@ -444,6 +446,164 @@ std::optional Load(const std::filesystem::path &skinsRoot, const std::s } } } + if (package.schemaVersion >= 2) + { + if (const toml::node *borderImageNode = window->get("border_image")) + { + const auto *borderImageTable = borderImageNode->as_table(); + BorderImage bi; + if (!borderImageTable || !borderImageTable->contains("source") || + !ReadResource(*borderImageTable, "source", bi.source)) + { + SetError(error, "candidate_window.border_image.source 无效"); + return std::nullopt; + } + const auto *sliceArr = (*borderImageTable)["slice_dip"].as_array(); + if (!sliceArr || (sliceArr->size() != 1 && sliceArr->size() != 2 && sliceArr->size() != 4)) + { + SetError(error, "candidate_window.border_image.slice_dip 必须包含 1、2 或 4 个数值"); + return std::nullopt; + } + for (const auto &val : *sliceArr) + { + const auto num = val.value(); + if (!num || *num < 0.0 || *num > 500.0) + { + SetError(error, "candidate_window.border_image.slice_dip 超出范围"); + return std::nullopt; + } + bi.sliceDip.push_back(*num); + } + if (const auto *stretchArr = (*borderImageTable)["stretch_dip"].as_array()) + { + if (stretchArr->size() != 1 && stretchArr->size() != 2 && stretchArr->size() != 4) + { + SetError(error, "candidate_window.border_image.stretch_dip 必须包含 1、2 或 4 个数值"); + return std::nullopt; + } + for (const auto &val : *stretchArr) + { + const auto num = val.value(); + if (!num || *num < 0.0 || *num > 500.0) + { + SetError(error, "candidate_window.border_image.stretch_dip 超出范围"); + return std::nullopt; + } + bi.stretchDip.push_back(*num); + } + } + if (const auto *widthArr = (*borderImageTable)["width_dip"].as_array()) + { + if (widthArr->size() != 1 && widthArr->size() != 2 && widthArr->size() != 4) + { + SetError(error, "candidate_window.border_image.width_dip 必须包含 1、2 或 4 个数值"); + return std::nullopt; + } + for (const auto &val : *widthArr) + { + const auto num = val.value(); + if (!num || *num < 0.0 || *num > 500.0) + { + SetError(error, "candidate_window.border_image.width_dip 超出范围"); + return std::nullopt; + } + bi.widthDip.push_back(*num); + } + } + bi.fill = (*borderImageTable)["fill"].value_or(true); + package.borderImage = bi; + } + + if (const toml::node *insetsNode = window->get("content_insets_dip")) + { + const auto *insetsTable = insetsNode->as_table(); + if (!insetsTable) + { + SetError(error, "candidate_window.content_insets_dip 必须是表"); + return std::nullopt; + } + InsetsDip insets; + insets.top = BoundedNumber(*insetsTable, "top", 200.0); + insets.right = BoundedNumber(*insetsTable, "right", 500.0); + insets.bottom = BoundedNumber(*insetsTable, "bottom", 200.0); + insets.left = BoundedNumber(*insetsTable, "left", 500.0); + if (insets.top < 0.0 || insets.right < 0.0 || insets.bottom < 0.0 || insets.left < 0.0) + { + SetError(error, "candidate_window.content_insets_dip 数值超出范围"); + return std::nullopt; + } + package.contentInsetsDip = insets; + } + + if (!ReadOptionalBounded(*window, "line_gap_dip", 100.0, package.lineGapDip)) + { + SetError(error, "candidate_window.line_gap_dip 超出范围"); + return std::nullopt; + } + + if (const toml::node *outerNode = window->get("outer_insets_dip")) + { + const auto *outerTable = outerNode->as_table(); + if (!outerTable) + { + SetError(error, "candidate_window.outer_insets_dip 必须是表"); + return std::nullopt; + } + InsetsDip outer; + outer.top = BoundedNumber(*outerTable, "top", 500.0); + outer.right = BoundedNumber(*outerTable, "right", 500.0); + outer.bottom = BoundedNumber(*outerTable, "bottom", 500.0); + outer.left = BoundedNumber(*outerTable, "left", 500.0); + if (outer.top < 0.0 || outer.right < 0.0 || outer.bottom < 0.0 || outer.left < 0.0) + { + SetError(error, "candidate_window.outer_insets_dip 数值超出范围"); + return std::nullopt; + } + package.outerInsetsDip = outer; + } + + if (const toml::node *decorationsNode = window->get("decorations")) + { + const auto *decorationsArr = decorationsNode->as_array(); + if (!decorationsArr) + { + SetError(error, "candidate_window.decorations 必须是数组"); + return std::nullopt; + } + for (const auto &decNode : *decorationsArr) + { + const auto *decTable = decNode.as_table(); + FreeDecoration dec; + if (!decTable || !decTable->contains("image") || !ReadResource(*decTable, "image", dec.image)) + { + SetError(error, "candidate_window.decorations.image 无效"); + return std::nullopt; + } + if (decTable->contains("anchor") && + !ReadEnum(*decTable, "anchor", + {"top-left", "top-right", "bottom-left", "bottom-right", "left", "right", "top", + "bottom", "center"}, + dec.anchor)) + { + SetError(error, "candidate_window.decorations.anchor 无效"); + return std::nullopt; + } + // 这三个键可选,缺省时 table::get 返回空指针,必须经 node_view 取值 + dec.offsetXDip = (*decTable)["offset_x_dip"].value_or(0.0); + dec.offsetYDip = (*decTable)["offset_y_dip"].value_or(0.0); + dec.widthDip = BoundedNumber(*decTable, "width_dip", 2000.0); + dec.heightDip = BoundedNumber(*decTable, "height_dip", 2000.0); + dec.zIndex = static_cast((*decTable)["z_index"].value_or(int64_t{2})); + if (dec.widthDip <= 0.0 || dec.heightDip <= 0.0) + { + SetError(error, "candidate_window.decorations 的 width_dip 与 height_dip 必须大于 0"); + return std::nullopt; + } + package.freeDecorations.push_back(dec); + } + } + } + const auto *candidate = root["candidate"].as_table(); if (candidate && (!ReadColors((*candidate)["dark"].as_table(), package.dark) || !ReadColors((*candidate)["light"].as_table(), package.light))) @@ -484,6 +644,21 @@ std::optional Load(const std::filesystem::path &skinsRoot, const std::s SetError(error, "找不到 candidate_window.background.image 文件"); return std::nullopt; } + if (package.borderImage && !package.borderImage->source.empty() && + !std::filesystem::is_regular_file(directory / std::filesystem::u8path(package.borderImage->source), ec)) + { + SetError(error, "找不到 candidate_window.border_image.source 文件"); + return std::nullopt; + } + for (const auto &dec : package.freeDecorations) + { + if (!dec.image.empty() && + !std::filesystem::is_regular_file(directory / std::filesystem::u8path(dec.image), ec)) + { + SetError(error, "找不到 candidate_window.decorations.image 文件"); + return std::nullopt; + } + } return package; } catch (const toml::parse_error &) diff --git a/server/src/skin/candidate_skin_catalog.h b/server/src/skin/candidate_skin_catalog.h index dd8783e4..25cbd5b0 100644 --- a/server/src/skin/candidate_skin_catalog.h +++ b/server/src/skin/candidate_skin_catalog.h @@ -57,8 +57,44 @@ struct ToolbarColors std::string hover; }; +struct BorderImage +{ + std::string source; + // 四边切片大小(DIP):top, right, bottom, left + std::vector sliceDip; + // 可拉伸区(上右下左,从面板外缘量起):给出时覆盖 sliceDip。 + // 面板图里画死的装饰必须落在可拉伸区之外,否则会被横向拉长。 + std::vector stretchDip; + // 四边宽度(DIP):top, right, bottom, left;若为空则沿用 sliceDip + std::vector widthDip; + // 是否填充中间内容区域 + bool fill = true; +}; + +struct InsetsDip +{ + double top = 0.0; + double right = 0.0; + double bottom = 0.0; + double left = 0.0; +}; + +struct FreeDecoration +{ + std::string image; + // 锚定基准:top-left, top-right, bottom-left, bottom-right, left, right, top, bottom, center + std::string anchor = "right"; + double offsetXDip = 0.0; + double offsetYDip = 0.0; + double widthDip = 0.0; + double heightDip = 0.0; + int zIndex = 2; +}; + struct Package { + // 皮肤规范版本:1 为通用现代卡片皮肤,2 为支持九宫格与自由挂件的高级皮肤。 + int schemaVersion = 1; std::string id; std::string name; std::string version; @@ -97,6 +133,17 @@ struct Package std::optional toolbarCornerRadiusDip; ToolbarColors toolbarDark; ToolbarColors toolbarLight; + // v2 专有特性:九宫格贴图边框 + std::optional borderImage; + // v2 专有特性:面板内容/文字安全内边距,从面板外缘量起(渲染时扣掉边框宽度)。 + std::optional contentInsetsDip; + // v2 专有特性:预编辑行与候选行之间的行距(DIP,0–100);未设置时沿用 base 的行距。 + std::optional lineGapDip; + // v2 专有特性:面板之外需要额外预留的窗口空间(上右下左 DIP),给挂在卡片外沿的挂件用。 + // 不声明时窗口只按卡片大小开,挂到外面的立绘会被窗口边界裁掉。 + std::optional outerInsetsDip; + // v2 专有特性:多层自由锚定挂件(支持动图) + std::vector freeDecorations; }; struct Issue diff --git a/server/src/webview2/windows_webview2.h b/server/src/webview2/windows_webview2.h index 9fb2590a..70609234 100644 --- a/server/src/webview2/windows_webview2.h +++ b/server/src/webview2/windows_webview2.h @@ -75,6 +75,11 @@ bool ApplyConfiguredFloatingToolbarAppearance(std::function onComplete); // Geometry declared by the active external skin package. Built-in skins return 0. double GetActiveCandidateSkinDecorationTopDip(); double GetActiveCandidateSkinDecorationWidthDip(); +// v2 皮肤为挂在卡片外沿的挂件声明的预留空白(DIP)。没有就返回 0。 +double GetActiveCandidateSkinOuterTopDip(); +double GetActiveCandidateSkinOuterRightDip(); +double GetActiveCandidateSkinOuterBottomDip(); +double GetActiveCandidateSkinOuterLeftDip(); // Push half-monitor CSS max width/height (DIP) into a small-window page. void InjectSurfaceViewportLimits(ICoreWebView2 *webview, HWND hwnd); // WebView2 rasterization scale includes both monitor DPI and the user's text diff --git a/server/src/webview2/windows_webview2_skin.cpp b/server/src/webview2/windows_webview2_skin.cpp index 686d0d55..5e9e1aca 100644 --- a/server/src/webview2/windows_webview2_skin.cpp +++ b/server/src/webview2/windows_webview2_skin.cpp @@ -38,6 +38,45 @@ double GetActiveCandidateSkinDecorationWidthDip() return activeExternalCandidateSkin ? activeExternalCandidateSkin->decorationWidthDip : 0.0; } +namespace +{ +double ActiveOuterInset(bool top, bool right, bool bottom) +{ + if (!activeExternalCandidateSkin || !activeExternalCandidateSkin->outerInsetsDip) + { + return 0.0; + } + const CandidateSkinCatalog::InsetsDip &insets = *activeExternalCandidateSkin->outerInsetsDip; + if (top) + return insets.top; + if (right) + return insets.right; + if (bottom) + return insets.bottom; + return insets.left; +} +} // namespace + +double GetActiveCandidateSkinOuterTopDip() +{ + return ActiveOuterInset(true, false, false); +} + +double GetActiveCandidateSkinOuterRightDip() +{ + return ActiveOuterInset(false, true, false); +} + +double GetActiveCandidateSkinOuterBottomDip() +{ + return ActiveOuterInset(false, false, true); +} + +double GetActiveCandidateSkinOuterLeftDip() +{ + return ActiveOuterInset(false, false, false); +} + std::wstring ReadHtmlFile(const std::wstring &filePath) { std::ifstream file(filePath, std::ios::binary); @@ -367,6 +406,13 @@ std::wstring BuildExternalCandidateSkinCss(const CandidateSkinCatalog::Package & css.append(L" center / contain no-repeat; pointer-events: none; }\n" L".container { position: relative; z-index: 1; }\n"); } + if (skin.outerInsetsDip && (skin.outerInsetsDip->left > 0.0 || skin.outerInsetsDip->top > 0.0)) + { + // MarginLeft / MarginTop 指向外留白信封的左上角,这里把卡片连同挂件相对定位回卡片原位。 + // 用相对定位而不是 padding:.containerParent 是挂件的定位容器,加 padding 会把挂件锚点挪到信封上。 + css.append(fmt::format(L".containerParent {{ position: relative; left: {}px; top: {}px; }}\n", + skin.outerInsetsDip->left, skin.outerInsetsDip->top)); + } AppendExternalCandidateCornerCss(css, skin); const bool light = ResolveConfiguredTheme(GetConfiguredThemeCand()) == "light"; const CandidateSkinCatalog::CandidateColors &colors = light ? skin.light : skin.dark; @@ -394,9 +440,151 @@ std::wstring BuildExternalCandidateSkinCss(const CandidateSkinCatalog::Package & L"background-clip: border-box; }}\n", size)); } + if (skin.borderImage) + { + // v2 专有九宫格贴图边框。stretch_dip 给出时它才是可拉伸区,语义比 slice_dip 直白: + // 面板图里画死的装饰放在它之外就保持 1:1,不会被横向拉长。 + const auto &bi = *skin.borderImage; + const std::vector &sliceSrc = bi.stretchDip.empty() ? bi.sliceDip : bi.stretchDip; + std::wstring sliceStr; + for (size_t i = 0; i < sliceSrc.size(); ++i) + { + if (i > 0) + sliceStr += L" "; + sliceStr += fmt::format(L"{}", sliceSrc[i]); + } + if (bi.fill) + sliceStr += L" fill"; + + const std::vector &widthSrc = bi.widthDip.empty() ? sliceSrc : bi.widthDip; + std::wstring widthStr; + for (size_t i = 0; i < widthSrc.size(); ++i) + { + if (i > 0) + widthStr += L" "; + widthStr += fmt::format(L"{}px", widthSrc[i]); + } + + css.append(L".container:not(:empty) { border-style: solid; border-image-source: "); + css.append(ManifestImageCssUrl(skinsRoot, skin.id, bi.source)); + // border-width 决定内容盒的内缩量,必须与 border-image-width 一致;只写 border-image-width + // 时内容盒仍按基础皮肤的 border 宽度内缩,文字会压在贴图上。 + css.append(fmt::format(L"; border-width: {}; border-image-slice: {}; border-image-width: {}; " + L"border-image-repeat: stretch; " + L"background-color: transparent; border-color: transparent; border-radius: 0; }}\n", + widthStr, sliceStr, widthStr)); + } + if (skin.contentInsetsDip) + { + // 四值展开成「上右下左」,1 值全边、2 值纵横、4 值四边,与 CSS 一致。 + const auto expand4 = [](const std::vector &v, double out[4]) { + if (v.size() == 1) + out[0] = out[1] = out[2] = out[3] = v[0]; + else if (v.size() == 2) + { + out[0] = out[2] = v[0]; + out[1] = out[3] = v[1]; + } + else + { + for (size_t i = 0; i < 4; ++i) + out[i] = i < v.size() ? v[i] : 0.0; + } + }; + double borderPx[4] = {0.0, 0.0, 0.0, 0.0}; + if (skin.borderImage) + { + const auto &bi = *skin.borderImage; + const std::vector &widthSrc = + bi.widthDip.empty() ? (bi.stretchDip.empty() ? bi.sliceDip : bi.stretchDip) : bi.widthDip; + expand4(widthSrc, borderPx); + } + // content_insets_dip 从面板外缘量起,而 CSS 的 padding 从边框内缘起算,这里把边框宽度扣掉。 + const auto &insets = *skin.contentInsetsDip; + const double raw[4] = {insets.top, insets.right, insets.bottom, insets.left}; + double padding[4] = {0.0, 0.0, 0.0, 0.0}; + for (size_t i = 0; i < 4; ++i) + padding[i] = (std::max)(0.0, raw[i] - borderPx[i]); + css.append(fmt::format(L".container:not(:empty) {{ padding: {}px {}px {}px {}px !important; }}\n", padding[0], + padding[1], padding[2], padding[3])); + } + if (skin.lineGapDip) + { + // 预编辑行与候选行之间的行距。基础皮肤把它并进 .row 的上下边距,这里显式覆盖。 + css.append(fmt::format(L".row.pinyin {{ margin-bottom: {}px; }}\n", *skin.lineGapDip)); + } + // 自由挂件:数量不限。候选框页面里没有能承载任意数量挂件的元素,所以按皮肤声明逐个生成 + // 一份 CSS 规则,由 InjectExternalCandidateSkin 注入对应数量的空 div,挂件层数不再受 + // ::before / ::after 两层限制。 + if (!skin.freeDecorations.empty()) + { + css.append(L".containerParent { position: relative; }\n"); + css.append(L".msime-skin-decor { position: absolute; pointer-events: none; " + L"background-position: center; background-repeat: no-repeat; background-size: contain; }\n"); + } + for (size_t idx = 0; idx < skin.freeDecorations.size(); ++idx) + { + const auto &dec = skin.freeDecorations[idx]; + // offset 语义统一:正数向右、向下,与 D2D 侧 CandidateSkinOverlay 一致。 + const double ox = dec.offsetXDip; + const double oy = dec.offsetYDip; + std::wstring posRule; + if (dec.anchor == "top-left") + posRule = fmt::format(L"left: {}px; top: {}px;", ox, oy); + else if (dec.anchor == "top-right") + posRule = fmt::format(L"right: {}px; top: {}px;", -ox, oy); + else if (dec.anchor == "bottom-left") + posRule = fmt::format(L"left: {}px; bottom: {}px;", ox, -oy); + else if (dec.anchor == "bottom-right") + posRule = fmt::format(L"right: {}px; bottom: {}px;", -ox, -oy); + else if (dec.anchor == "left") + posRule = fmt::format(L"left: {}px; top: 50%; transform: translateY(calc(-50% + {}px));", ox, oy); + else if (dec.anchor == "right") + posRule = fmt::format(L"right: {}px; top: 50%; transform: translateY(calc(-50% + {}px));", -ox, oy); + else if (dec.anchor == "top") + posRule = fmt::format(L"top: {}px; left: 50%; transform: translateX(calc(-50% + {}px));", oy, ox); + else if (dec.anchor == "bottom") + posRule = fmt::format(L"bottom: {}px; left: 50%; transform: translateX(calc(-50% + {}px));", -oy, ox); + else + posRule = fmt::format(L"left: 50%; top: 50%; " + L"transform: translate(calc(-50% + {}px), calc(-50% + {}px));", + ox, oy); + + css.append(fmt::format(L".msime-skin-decor[data-i=\"{}\"] {{ z-index: {}; {} width: {}px; height: {}px; " + L"background-image: ", + idx, dec.zIndex, posRule, dec.widthDip, dec.heightDip)); + css.append(ManifestImageCssUrl(skinsRoot, skin.id, dec.image)); + css.append(L"; }\n"); + } return css; } +// 挂件层数的 CSS 已经按 data-i 生成,这里把对应数量的空 div 补进 .containerParent。 +// 页面自己的脚本只换 .container 的内容,不动 .containerParent,所以这些 div 不会被打掉。 +std::wstring BuildFreeDecorationLayerScript(const CandidateSkinCatalog::Package &skin) +{ + if (skin.freeDecorations.empty()) + { + return L""; + } + std::wstring indices; + for (size_t i = 0; i < skin.freeDecorations.size(); ++i) + { + if (i > 0) + indices += L","; + indices += std::to_wstring(i); + } + return L""; +} + bool InjectExternalCandidateSkin(std::wstring &html, const CandidateSkinCatalog::Package &skin, const std::wstring &skinsRoot) { @@ -420,7 +608,7 @@ bool InjectExternalCandidateSkin(std::wstring &html, const CandidateSkinCatalog: { return false; } - html.insert(headEnd, vars + generated); + html.insert(headEnd, vars + generated + BuildFreeDecorationLayerScript(skin)); return true; } diff --git a/server/src/window/candidate_presenter.cpp b/server/src/window/candidate_presenter.cpp index 7a6aaa54..54888417 100644 --- a/server/src/window/candidate_presenter.cpp +++ b/server/src/window/candidate_presenter.cpp @@ -8,6 +8,7 @@ #include "ipc/ipc.h" #include "log/candidate_diag_log.h" #include "skin/candidate_skin_catalog.h" +#include "window/candidate_skin_overlay.h" #include "utils/common_utils.h" #include "utils/ime_utils.h" #include "utils/window_utils.h" @@ -335,6 +336,9 @@ struct CandidatePresenter::Impl std::shared_ptr root; std::shared_ptr decoration; std::shared_ptr card; + // 卡片与 v2 挂件层放在同一格里:挂件层测量为 0,格子仍由卡片决定,挂件按 anchor 画到卡片外沿。 + std::shared_ptr overlayGrid; + std::shared_ptr skinOverlay; // 装饰图与卡片同列:列宽即卡片宽,装饰图在列内按 align 对齐,对应 CSS 里 // .containerParent 为 fit-content、::before 相对它定位。 std::shared_ptr cardColumn; @@ -449,11 +453,19 @@ void CandidatePresenter::RebuildScene() impl_->card->SetHorizontalAlignment(msimeui::HorizontalAlignment::Leading); impl_->card->AddChild(impl_->body); impl_->root->SetHorizontalContentAlignment(msimeui::HorizontalAlignment::Leading); + impl_->overlayGrid = std::make_shared(); + impl_->overlayGrid->AddRow(msimeui::GridLength{msimeui::GridUnitType::Auto, 1.0f}); + impl_->overlayGrid->AddColumn(msimeui::GridLength{msimeui::GridUnitType::Auto, 1.0f}); + impl_->overlayGrid->AddChild(impl_->card, 0, 0); + impl_->skinOverlay = std::make_shared(); + impl_->skinOverlay->SetHorizontalAlignment(msimeui::HorizontalAlignment::Stretch); + impl_->skinOverlay->SetVerticalAlignment(msimeui::VerticalAlignment::Stretch); + impl_->overlayGrid->AddChild(impl_->skinOverlay, 0, 0); impl_->cardColumn = std::make_shared(0.0f); impl_->cardColumn->SetHorizontalAlignment(msimeui::HorizontalAlignment::Leading); impl_->cardColumn->SetHorizontalContentAlignment(msimeui::HorizontalAlignment::Stretch); impl_->cardColumn->AddChild(impl_->decoration); - impl_->cardColumn->AddChild(impl_->card); + impl_->cardColumn->AddChild(impl_->overlayGrid); impl_->frame = std::make_shared(); impl_->frame->SetPadding({kShadowPadLeft, kShadowPadTop, kShadowPadRight, kShadowPadBottom}); impl_->frame->SetChild(impl_->cardColumn); @@ -1000,9 +1012,49 @@ void CandidatePresenter::ApplySkin() impl_->decoration->SetHorizontalAlignment(DecorationAlignment(package->decorationAlign)); framePadTop = (std::max)(0.0f, kShadowPadTop - decorationTopDip_); } + // v2 挂件挂在卡片外沿,窗口要按 outer_insets_dip 往四边多留出空白,否则立绘被窗口边界裁掉。 + float framePadLeft = kShadowPadLeft; + float framePadRight = kShadowPadRight; + float framePadBottom = kShadowPadBottom; + if (package && package->outerInsetsDip) + { + const CandidateSkinCatalog::InsetsDip &outer = *package->outerInsetsDip; + framePadLeft += static_cast(outer.left); + framePadRight += static_cast(outer.right); + framePadTop += static_cast(outer.top); + framePadBottom += static_cast(outer.bottom); + } if (impl_->frame) { - impl_->frame->SetPadding({kShadowPadLeft, framePadTop, kShadowPadRight, kShadowPadBottom}); + impl_->frame->SetPadding({framePadLeft, framePadTop, framePadRight, framePadBottom}); + } + + // v2 自由挂件交给 overlay 层绘制;它测量为 0,不参与卡片布局。 + if (impl_->skinOverlay) + { + std::vector items; + if (package) + { + for (const CandidateSkinCatalog::FreeDecoration &dec : package->freeDecorations) + { + CandidateSkinOverlay::Item item; + item.path = packageRoot + string_to_wstring(dec.image); + item.anchor = dec.anchor; + item.offsetXDip = static_cast(dec.offsetXDip); + item.offsetYDip = static_cast(dec.offsetYDip); + item.widthDip = static_cast(dec.widthDip); + item.heightDip = static_cast(dec.heightDip); + items.push_back(std::move(item)); + } + } + impl_->skinOverlay->SetItems(std::move(items)); + } + // v2 的 line_gap_dip 落到预编辑行与候选列表之间的间距上。 + // TODO(v2 皮肤): 暂定 WebView2 独占。SetSpacing 作用于 body 的全部子元素,竖排时列表与翻页栏之间也会被拉开, + // WebView2 只加在 .row.pinyin 下方。 + if (impl_->body) + { + impl_->body->SetSpacing(package && package->lineGapDip ? static_cast(*package->lineGapDip) : 2.0f); } msimeui::Brush brush; @@ -1014,10 +1066,50 @@ void CandidatePresenter::ApplySkin() if (impl_->card) { impl_->card->SetBrush(brush); - impl_->card->SetPadding(tokens.containerPad); + if (package && package->contentInsetsDip) + { + // v2 的 content_insets_dip 从面板外缘量起,D2D 侧直接落成卡片内边距(这里没有 CSS 的边框内缘概念)。 + const CandidateSkinCatalog::InsetsDip &insets = *package->contentInsetsDip; + impl_->card->SetPadding(msimeui::Thickness{static_cast(insets.left), static_cast(insets.top), + static_cast(insets.right), + static_cast(insets.bottom)}); + } + else + { + impl_->card->SetPadding(tokens.containerPad); + } impl_->card->SetMinWidth(cardMinWidthDip_); - impl_->card->SetBackgroundImage(backgroundPath, BackgroundStretch(package ? package->backgroundFit : ""), - package ? static_cast(package->backgroundOpacity) : 1.0f); + if (package && package->borderImage && !package->borderImage->source.empty()) + { + // v2 九宫格:四边原样、其余拉伸。切片按 1/2/4 值展开成「上右下左」。 + // TODO(v2 皮肤): 暂定 WebView2 独占。D2D 尚未处理 width_dip 与 fill = false,九宫格仍叠在卡片底色、 + // 描边与阴影之上并被卡片圆角裁剪;WebView2 会把这些清成透明、圆角归零。 + const CandidateSkinCatalog::BorderImage &bi = *package->borderImage; + const std::vector &values = bi.stretchDip.empty() ? bi.sliceDip : bi.stretchDip; + const auto expand4 = [](const std::vector &v, float out[4]) { + if (v.size() == 1) + out[0] = out[1] = out[2] = out[3] = static_cast(v[0]); + else if (v.size() == 2) + { + out[0] = out[2] = static_cast(v[0]); + out[1] = out[3] = static_cast(v[1]); + } + else + { + for (size_t i = 0; i < 4; ++i) + out[i] = i < v.size() ? static_cast(v[i]) : 0.0f; + } + }; + float slice[4] = {0.0f, 0.0f, 0.0f, 0.0f}; + expand4(values, slice); + impl_->card->SetBackgroundImageNineSlice(packageRoot + string_to_wstring(bi.source), slice[3], slice[0], + slice[1], slice[2], 1.0f); + } + else + { + impl_->card->SetBackgroundImage(backgroundPath, BackgroundStretch(package ? package->backgroundFit : ""), + package ? static_cast(package->backgroundOpacity) : 1.0f); + } } } diff --git a/server/src/window/candidate_skin_overlay.cpp b/server/src/window/candidate_skin_overlay.cpp new file mode 100644 index 00000000..2ba099ba --- /dev/null +++ b/server/src/window/candidate_skin_overlay.cpp @@ -0,0 +1,102 @@ +#include "window/candidate_skin_overlay.h" + +#include "msimeui/DeviceResources.h" + +#include + +void CandidateSkinOverlay::SetItems(std::vector items) +{ + items_ = std::move(items); + InvalidateVisual(); +} + +msimeui::SizeF CandidateSkinOverlay::Measure(const msimeui::SizeF & /*availableSize*/) +{ + // 挂件是浮在卡片外面的装饰,不参与测量:测量为 0 时父级网格的格子仍由卡片决定, + // 卡片不会被立绘撑大或被挤偏。 + return {0.0f, 0.0f}; +} + +void CandidateSkinOverlay::Arrange(const msimeui::RectF &finalRect) +{ + bounds_ = finalRect; +} + +bool CandidateSkinOverlay::HitTest(const msimeui::PointF & /*point*/) const +{ + return false; +} + +// TODO(v2 皮肤): v2 自由挂件暂定 WebView2 独占,D2D 这里尚未按 z_index 分层,全部按声明顺序画在卡片之上。 +void CandidateSkinOverlay::Render(msimeui::DeviceResources &deviceResources) +{ + if (items_.empty() || bounds_.width <= 0.0f || bounds_.height <= 0.0f) + { + return; + } + ID2D1RenderTarget *target = deviceResources.GetRenderTarget(); + if (!target) + { + return; + } + for (const Item &item : items_) + { + D2D1_SIZE_F sourceSize = {}; + ID2D1Bitmap *bitmap = deviceResources.GetBitmapFromFile(item.path, &sourceSize); + if (!bitmap || sourceSize.width <= 0.0f || sourceSize.height <= 0.0f) + { + continue; + } + const float width = item.widthDip > 0.0f ? item.widthDip : sourceSize.width; + const float height = item.heightDip > 0.0f ? item.heightDip : sourceSize.height; + const float right = bounds_.x + bounds_.width; + const float bottom = bounds_.y + bounds_.height; + float x = right - width; + float y = bounds_.y + (bounds_.height - height) * 0.5f; + const std::string &anchor = item.anchor; + if (anchor == "top-left") + { + x = bounds_.x; + y = bounds_.y; + } + else if (anchor == "top-right") + { + x = right - width; + y = bounds_.y; + } + else if (anchor == "bottom-left") + { + x = bounds_.x; + y = bottom - height; + } + else if (anchor == "bottom-right") + { + x = right - width; + y = bottom - height; + } + else if (anchor == "left") + { + x = bounds_.x; + } + else if (anchor == "top") + { + x = bounds_.x + (bounds_.width - width) * 0.5f; + y = bounds_.y; + } + else if (anchor == "bottom") + { + x = bounds_.x + (bounds_.width - width) * 0.5f; + y = bottom - height; + } + else if (anchor == "center") + { + x = bounds_.x + (bounds_.width - width) * 0.5f; + y = bounds_.y + (bounds_.height - height) * 0.5f; + } + x += item.offsetXDip; + y += item.offsetYDip; + const D2D1_RECT_F destination = D2D1::RectF(x, y, x + width, y + height); + const D2D1_RECT_F source = D2D1::RectF(0.0f, 0.0f, sourceSize.width, sourceSize.height); + target->DrawBitmap(bitmap, destination, 1.0f, D2D1_BITMAP_INTERPOLATION_MODE_LINEAR, &source); + } +} diff --git a/server/src/window/candidate_skin_overlay.h b/server/src/window/candidate_skin_overlay.h new file mode 100644 index 00000000..547458de --- /dev/null +++ b/server/src/window/candidate_skin_overlay.h @@ -0,0 +1,40 @@ +#pragma once + +#include "msimeui/Layout.h" + +#include +#include + +namespace msimeui +{ +class DeviceResources; +} + +// v2 皮肤的自由挂件层。它不参与布局(测量恒为 0),由父级摆到卡片那一格, +// 内部按 anchor + offset 把挂件画到卡片边界之外,所以窗口必须按 outer_insets_dip 多留出空间。 +class CandidateSkinOverlay : public msimeui::Visual +{ + public: + struct Item + { + std::wstring path; + // top-left / top-right / bottom-left / bottom-right / left / right / top / bottom / center + std::string anchor = "right"; + // 相对锚点的平移量,正数向右、向下。 + float offsetXDip = 0.0f; + float offsetYDip = 0.0f; + float widthDip = 0.0f; + float heightDip = 0.0f; + }; + + void SetItems(std::vector items); + + msimeui::SizeF Measure(const msimeui::SizeF &availableSize) override; + void Arrange(const msimeui::RectF &finalRect) override; + void Render(msimeui::DeviceResources &deviceResources) override; + // 挂件纯装饰,叠在卡片那一格上方,点击必须穿透给候选。 + bool HitTest(const msimeui::PointF &point) const override; + + private: + std::vector items_; +}; diff --git a/server/src/window/ime_windows_candidate_layout.cpp b/server/src/window/ime_windows_candidate_layout.cpp index 745d824d..916aa7f2 100644 --- a/server/src/window/ime_windows_candidate_layout.cpp +++ b/server/src/window/ime_windows_candidate_layout.cpp @@ -89,6 +89,26 @@ double GetCandidateDecorationWidthDip() { return GetActiveCandidateSkinDecorationWidthDip(); } + +double GetCandidateOuterTopDip() +{ + return GetActiveCandidateSkinOuterTopDip(); +} + +double GetCandidateOuterRightDip() +{ + return GetActiveCandidateSkinOuterRightDip(); +} + +double GetCandidateOuterBottomDip() +{ + return GetActiveCandidateSkinOuterBottomDip(); +} + +double GetCandidateOuterLeftDip() +{ + return GetActiveCandidateSkinOuterLeftDip(); +} } // namespace namespace ime_windows_detail @@ -104,11 +124,15 @@ int GetCandidatePackingMarginTopDip() std::pair AddCandidateDecorationToSize(const std::pair &cardSize) { const double decorationTopDip = GetCandidateDecorationTopDip(); - if (decorationTopDip <= 0.0) + std::pair size = cardSize; + if (decorationTopDip > 0.0) { - return cardSize; + size = {(std::max)(size.first, GetCandidateDecorationWidthDip()), size.second + decorationTopDip}; } - return {(std::max)(cardSize.first, GetCandidateDecorationWidthDip()), cardSize.second + decorationTopDip}; + // v2 挂件挂在卡片外沿:窗口在卡片四周再各留出一块透明空白,立绘才不会被窗口边界裁掉。 + size.first += GetCandidateOuterLeftDip() + GetCandidateOuterRightDip(); + size.second += GetCandidateOuterTopDip() + GetCandidateOuterBottomDip(); + return size; } int GetCandidateOuterTopPx(int cardAnchorY, int packingTopDip, FLOAT scale) @@ -117,10 +141,20 @@ int GetCandidateOuterTopPx(int cardAnchorY, int packingTopDip, FLOAT scale) { scale = 1.0f; } - const double outerOffsetDip = static_cast(packingTopDip) - GetCandidateDecorationTopDip(); + const double outerOffsetDip = + static_cast(packingTopDip) - GetCandidateDecorationTopDip() - GetCandidateOuterTopDip(); return cardAnchorY + static_cast(std::lround(outerOffsetDip * static_cast(scale))); } +int GetCandidateOuterLeftPx(int cardAnchorX, FLOAT scale) +{ + if (scale <= 0.0f) + { + scale = 1.0f; + } + return cardAnchorX - static_cast(std::lround(GetCandidateOuterLeftDip() * static_cast(scale))); +} + void RememberCandidateClip(const std::pair &decoratedSize, FLOAT scale, int caretX, int caretY) { g_has_last_candidate_clip = decoratedSize.first > 1.0 && decoratedSize.second > 1.0; @@ -257,8 +291,9 @@ void ReanchorCandidateHostToPaintedCard(const POINT &layoutCaret, const std::pai hostY = coordinates.top + edgePadPx; } } + const int desiredOuterLeftPx = GetCandidateOuterLeftPx(anchorPos->first, scale); Global::MarginLeft = - (std::max)(0, static_cast(std::lround((anchorPos->first - hostX) / static_cast(scale)))); + (std::max)(0, static_cast(std::lround((desiredOuterLeftPx - hostX) / static_cast(scale)))); Global::MarginTop = GetCandidateOuterMarginDip(desiredOuterTopPx, hostY, scale); } diff --git a/server/src/window/ime_windows_candidate_show.cpp b/server/src/window/ime_windows_candidate_show.cpp index 694f1a1e..57196228 100644 --- a/server/src/window/ime_windows_candidate_show.cpp +++ b/server/src/window/ime_windows_candidate_show.cpp @@ -188,7 +188,8 @@ void PlaceCandidateHostNearCaret(HWND hwnd, std::pair requestedC RememberCandidateFlip(properPos->second, caretPt.y); MonitorCoordinates coordinates = GetMonitorCoordinatesFromPoint(caretPt); - int hostX = properPos->first - + const int desiredOuterLeftPx = GetCandidateOuterLeftPx(properPos->first, layoutScale); + int hostX = desiredOuterLeftPx - static_cast(std::lround(::CANDIDATE_SHADOW_PAD_LEFT * static_cast(layoutScale))); const int packingMarginTop = GetCandidatePackingMarginTopDip(); int desiredOuterTopPx = GetCandidateOuterTopPx(properPos->second, packingMarginTop, layoutScale); @@ -212,7 +213,8 @@ void PlaceCandidateHostNearCaret(HWND hwnd, std::pair requestedC hostY = coordinates.top + edgePadPx; } - const int offsetXDip = static_cast(std::lround((properPos->first - hostX) / static_cast(layoutScale))); + const int offsetXDip = + static_cast(std::lround((desiredOuterLeftPx - hostX) / static_cast(layoutScale))); Global::MarginLeft = (std::max)(0, offsetXDip); Global::MarginTop = GetCandidateOuterMarginDip(desiredOuterTopPx, hostY, layoutScale); const int marginLeftBeforeKeep = Global::MarginLeft; @@ -574,7 +576,8 @@ int FineTuneWindow(HWND hwnd) int hostHeightPx = hostPx.second; MonitorCoordinates coordinates = GetMonitorCoordinatesFromPoint(pt); - int hostX = properPos->first - + int desiredOuterLeftPx = GetCandidateOuterLeftPx(properPos->first, layoutScale); + int hostX = desiredOuterLeftPx - static_cast(std::lround(::CANDIDATE_SHADOW_PAD_LEFT * static_cast(layoutScale))); const int packingMarginTop = GetCandidatePackingMarginTopDip(); int desiredOuterTopPx = GetCandidateOuterTopPx(properPos->second, packingMarginTop, layoutScale); @@ -603,7 +606,7 @@ int FineTuneWindow(HWND hwnd) auto applyCardMargins = [&](FLOAT marginScale) { const int offsetXDip = - static_cast(std::lround((properPos->first - hostX) / static_cast(marginScale))); + static_cast(std::lround((desiredOuterLeftPx - hostX) / static_cast(marginScale))); Global::MarginLeft = (std::max)(0, offsetXDip); Global::MarginTop = GetCandidateOuterMarginDip(desiredOuterTopPx, hostY, marginScale); }; @@ -693,7 +696,8 @@ int FineTuneWindow(HWND hwnd) AdjustCandidateWindowPosition(&pt, containerSize, properPos, layoutScale, containerSize.first); RememberCandidateFlip(properPos->second, caretY); const int resyncPackingMarginTop = GetCandidatePackingMarginTopDip(); - hostX = properPos->first - + desiredOuterLeftPx = GetCandidateOuterLeftPx(properPos->first, layoutScale); + hostX = desiredOuterLeftPx - static_cast(std::lround(::CANDIDATE_SHADOW_PAD_LEFT * static_cast(layoutScale))); desiredOuterTopPx = GetCandidateOuterTopPx(properPos->second, resyncPackingMarginTop, layoutScale); hostY = desiredOuterTopPx - @@ -718,7 +722,7 @@ int FineTuneWindow(HWND hwnd) } const int offsetXDip = - static_cast(std::lround((properPos->first - hostX) / static_cast(layoutScale))); + static_cast(std::lround((desiredOuterLeftPx - hostX) / static_cast(layoutScale))); Global::MarginLeft = (std::max)(0, offsetXDip); Global::MarginTop = GetCandidateOuterMarginDip(desiredOuterTopPx, hostY, layoutScale); KeepCandidateCardInsideHostAndMonitor(hostX, hostY, hostWidthPx, hostHeightPx, decoratedSize.first, @@ -875,8 +879,9 @@ int FineTuneWindow(HWND hwnd) const int hostHeightPx = pass2Host.second; MonitorCoordinates coordinates = GetMonitorCoordinatesFromPoint(pt); - int hostX = properPos->first - static_cast(std::lround(::CANDIDATE_SHADOW_PAD_LEFT * - static_cast(pass2Scale))); + const int desiredOuterLeftPx = GetCandidateOuterLeftPx(properPos->first, pass2Scale); + int hostX = desiredOuterLeftPx - static_cast(std::lround(::CANDIDATE_SHADOW_PAD_LEFT * + static_cast(pass2Scale))); const int desiredOuterTopPx = GetCandidateOuterTopPx(properPos->second, packingTop, pass2Scale); int hostY = desiredOuterTopPx - @@ -900,8 +905,8 @@ int FineTuneWindow(HWND hwnd) hostY = coordinates.top + edgePadPx; } - const int offsetXDip = - static_cast(std::lround((properPos->first - hostX) / static_cast(pass2Scale))); + const int offsetXDip = static_cast( + std::lround((desiredOuterLeftPx - hostX) / static_cast(pass2Scale))); Global::MarginLeft = (std::max)(0, offsetXDip); Global::MarginTop = GetCandidateOuterMarginDip(desiredOuterTopPx, hostY, pass2Scale); KeepCandidateCardInsideHostAndMonitor(hostX, hostY, hostWidthPx, hostHeightPx, diff --git a/server/src/window/ime_windows_internal.h b/server/src/window/ime_windows_internal.h index 55c5f3ba..6ee5d455 100644 --- a/server/src/window/ime_windows_internal.h +++ b/server/src/window/ime_windows_internal.h @@ -109,6 +109,9 @@ double GetCandidateDecorationTopDip(); int GetCandidatePackingMarginTopDip(); std::pair AddCandidateDecorationToSize(const std::pair &cardSize); int GetCandidateOuterTopPx(int cardAnchorY, int packingTopDip, FLOAT scale); +// 外留白信封的左边(屏幕 px):卡片左边再往左让出 v2 的 outer_insets_dip.left。 +// MarginLeft 与 MarginTop 一样指向信封左上角,卡片由皮肤 CSS 相对定位回原位。 +int GetCandidateOuterLeftPx(int cardAnchorX, FLOAT scale); void ClipCandidateWindowToContent(HWND hwnd, const std::pair &containerSize, FLOAT scale, double extraTopDip = 0.0); void ClearCandidateWindowRegion(HWND hwnd); diff --git a/server/tests/src/test_candidate_skin_catalog_images.cpp b/server/tests/src/test_candidate_skin_catalog_images.cpp index 10603de4..4d642bfd 100644 --- a/server/tests/src/test_candidate_skin_catalog_images.cpp +++ b/server/tests/src/test_candidate_skin_catalog_images.cpp @@ -192,3 +192,140 @@ TEST_CASE(candidate_skin_catalog_rejects_invalid_toolbar_tables) REQUIRE(LoadFails(L"tb-radius", window + "[toolbar]\ncorner_radius_dip = 40\n")); REQUIRE(LoadFails(L"tb-radius-type", window + "[toolbar]\ncorner_radius_dip = \"8px\"\n")); } + +TEST_CASE(candidate_skin_catalog_reads_v2_features) +{ + namespace fs = std::filesystem; + const fs::path skins_root = + fs::temp_directory_path() / (L"msime-skin-v2-" + std::to_wstring(GetCurrentProcessId())) / 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\\border.png", L"assets\\char.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 << R"toml(schema_version = 2 +id = "art" +name = "Art" +version = "2.0.0" +base = "fluent" + +[supports] +layouts = ["horizontal"] +themes = ["dark"] + +[candidate_window] +line_gap_dip = 6 + +[candidate_window.border_image] +source = "assets/border.png" +slice_dip = [16, 44, 16, 0] +stretch_dip = [16, 90, 16, 0] +width_dip = [16, 44, 16, 0] +fill = true + +[candidate_window.content_insets_dip] +top = 4 +right = 130 +bottom = 14 +left = 55 + +[candidate_window.outer_insets_dip] +top = 87 +right = 16 +bottom = 4 +left = 0 + +[[candidate_window.decorations]] +image = "assets/char.png" +anchor = "right" +offset_x_dip = 10 +offset_y_dip = -15 +width_dip = 149 +height_dip = 161 +z_index = 3 +)toml"; + manifest.close(); + + std::string error; + const auto package = CandidateSkinCatalog::Load(skins_root, "art", &error); + REQUIRE(error.empty()); + REQUIRE(package.has_value()); + REQUIRE_EQ(package->schemaVersion, 2); + REQUIRE(package->borderImage.has_value()); + REQUIRE_EQ(package->borderImage->source, std::string("assets/border.png")); + REQUIRE_EQ(package->borderImage->sliceDip.size(), static_cast(4)); + REQUIRE_EQ(package->borderImage->sliceDip[1], 44.0); + // stretch_dip 与 slice_dip 分开存:前者才是可拉伸区,渲染时优先用它。 + REQUIRE_EQ(package->borderImage->stretchDip.size(), static_cast(4)); + REQUIRE_EQ(package->borderImage->stretchDip[1], 90.0); + REQUIRE(package->borderImage->fill); + + REQUIRE(package->contentInsetsDip.has_value()); + REQUIRE_EQ(package->contentInsetsDip->left, 55.0); + REQUIRE_EQ(package->contentInsetsDip->right, 130.0); + + REQUIRE(package->lineGapDip.has_value()); + REQUIRE_EQ(*package->lineGapDip, 6.0); + + REQUIRE(package->outerInsetsDip.has_value()); + REQUIRE_EQ(package->outerInsetsDip->top, 87.0); + REQUIRE_EQ(package->outerInsetsDip->right, 16.0); + + REQUIRE_EQ(package->freeDecorations.size(), static_cast(1)); + REQUIRE_EQ(package->freeDecorations[0].anchor, std::string("right")); + REQUIRE_EQ(package->freeDecorations[0].widthDip, 149.0); + REQUIRE_EQ(package->freeDecorations[0].zIndex, 3); + + fs::remove_all(skins_root, ec); +} + +TEST_CASE(candidate_skin_catalog_defaults_optional_decoration_keys) +{ + namespace fs = std::filesystem; + const fs::path skins_root = + fs::temp_directory_path() / (L"msime-skin-v2-decor-" + std::to_wstring(GetCurrentProcessId())) / L"skins"; + std::error_code ec; + fs::remove_all(skins_root, ec); + fs::create_directories(skins_root / L"art" / L"assets", ec); + REQUIRE(!ec); + { + std::ofstream file(skins_root / L"art" / L"assets" / L"char.png", 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()); + // offset_x_dip、offset_y_dip、z_index 都可省略;缺省时不能解引用空节点。 + manifest << R"toml(schema_version = 2 +id = "art" +name = "Art" +version = "2.0.0" +base = "fluent" + +[supports] +layouts = ["horizontal"] +themes = ["dark"] + +[[candidate_window.decorations]] +image = "assets/char.png" +width_dip = 40 +height_dip = 30 +)toml"; + manifest.close(); + + std::string error; + const auto package = CandidateSkinCatalog::Load(skins_root, "art", &error); + REQUIRE(error.empty()); + REQUIRE(package.has_value()); + REQUIRE_EQ(package->freeDecorations.size(), static_cast(1)); + REQUIRE_EQ(package->freeDecorations[0].offsetXDip, 0.0); + REQUIRE_EQ(package->freeDecorations[0].offsetYDip, 0.0); + REQUIRE_EQ(package->freeDecorations[0].zIndex, 2); + + fs::remove_all(skins_root, ec); +} diff --git a/ui/include/msimeui/Layout.h b/ui/include/msimeui/Layout.h index 1043913f..5aa3299f 100644 --- a/ui/include/msimeui/Layout.h +++ b/ui/include/msimeui/Layout.h @@ -168,6 +168,7 @@ class StackPanel : public Panel { public: explicit StackPanel(float spacing = 0.0f); + void SetSpacing(float spacing); void SetHorizontalContentAlignment(HorizontalAlignment alignment); void SetVerticalContentAlignment(VerticalAlignment alignment); @@ -362,6 +363,8 @@ class Card : public Panel void SetBrush(Brush brush); void SetPadding(float padding); + // 四边独立的内边距,给 v2 皮肤的 content_insets_dip 用。 + void SetPadding(Thickness padding); void SetShadowScale(float scale); // Multiplies every shadow pass alpha; 1.0 keeps the built-in intensity. void SetShadowOpacity(float opacity); @@ -374,6 +377,10 @@ class Card : public Panel // card shape. An empty path removes it. void SetBackgroundImage(std::wstring filePath, ImageStretch stretch = ImageStretch::UniformToFill, float opacity = 1.0f); + // 九宫格背景:四边按切片原样绘制,其余单向/双向拉伸,用来铺「面板即贴图」的 v2 皮肤。 + // 切片单位与源图像素一致;给出后覆盖 SetBackgroundImage 的普通背景。 + void SetBackgroundImageNineSlice(std::wstring filePath, float left, float top, float right, float bottom, + float opacity = 1.0f); SizeF Measure(const SizeF &availableSize) override; void Arrange(const RectF &finalRect) override; @@ -386,10 +393,15 @@ class Card : public Panel const D2D1_ROUNDED_RECT &shape); Brush brush_; - float padding_ = 0.0f; + Thickness padding_ = {}; std::wstring backgroundImage_; ImageStretch backgroundStretch_ = ImageStretch::UniformToFill; float backgroundOpacity_ = 1.0f; + bool backgroundNineSlice_ = false; + float sliceLeft_ = 0.0f; + float sliceTop_ = 0.0f; + float sliceRight_ = 0.0f; + float sliceBottom_ = 0.0f; float shadowScale_ = 1.0f; float shadowOpacity_ = 1.0f; std::vector shadowPasses_; diff --git a/ui/src/Layout.cpp b/ui/src/Layout.cpp index be2b294d..ca1cf39b 100644 --- a/ui/src/Layout.cpp +++ b/ui/src/Layout.cpp @@ -933,6 +933,16 @@ StackPanel::StackPanel(float spacing) : spacing_(spacing) { } +void StackPanel::SetSpacing(float spacing) +{ + if (spacing_ == spacing) + { + return; + } + spacing_ = spacing; + InvalidateMeasure(); +} + void StackPanel::SetHorizontalContentAlignment(HorizontalAlignment alignment) { horizontalContentAlignment_ = alignment; @@ -1835,7 +1845,7 @@ void Border::Render(DeviceResources &deviceResources) Container::Render(deviceResources); } -Card::Card(Brush brush, float padding) : brush_(brush), padding_(padding) +Card::Card(Brush brush, float padding) : brush_(brush), padding_{padding, padding, padding, padding} { } @@ -1847,7 +1857,13 @@ void Card::SetBrush(Brush brush) void Card::SetPadding(float padding) { - if (padding_ == padding) + SetPadding(Thickness{padding, padding, padding, padding}); +} + +void Card::SetPadding(Thickness padding) +{ + if (padding_.left == padding.left && padding_.top == padding.top && padding_.right == padding.right && + padding_.bottom == padding.bottom) { return; } @@ -1899,8 +1915,8 @@ void Card::SetShadowEnabled(bool enabled) SizeF Card::Measure(const SizeF &availableSize) { - const SizeF inner = {std::max(availableSize.width - padding_ * 2.0f, 0.0f), - std::max(availableSize.height - padding_ * 2.0f, 0.0f)}; + const SizeF inner = {std::max(availableSize.width - padding_.left - padding_.right, 0.0f), + std::max(availableSize.height - padding_.top - padding_.bottom, 0.0f)}; float width = 0.0f; float height = 0.0f; @@ -1911,16 +1927,16 @@ SizeF Card::Measure(const SizeF &availableSize) height = std::max(height, childSize_.height); } - return {width + padding_ * 2.0f, height + padding_ * 2.0f}; + return {width + padding_.left + padding_.right, height + padding_.top + padding_.bottom}; } void Card::Arrange(const RectF &finalRect) { bounds_ = finalRect; - const RectF inner = {finalRect.x + padding_, finalRect.y + padding_, - std::max(finalRect.width - padding_ * 2.0f, 0.0f), - std::max(finalRect.height - padding_ * 2.0f, 0.0f)}; + const RectF inner = {finalRect.x + padding_.left, finalRect.y + padding_.top, + std::max(finalRect.width - padding_.left - padding_.right, 0.0f), + std::max(finalRect.height - padding_.top - padding_.bottom, 0.0f)}; for (const auto &child : children_) { @@ -1964,16 +1980,41 @@ void Card::Render(DeviceResources &deviceResources) void Card::SetBackgroundImage(std::wstring filePath, ImageStretch stretch, float opacity) { const float clamped = std::clamp(opacity, 0.0f, 1.0f); - if (backgroundImage_ == filePath && backgroundStretch_ == stretch && backgroundOpacity_ == clamped) + if (backgroundImage_ == filePath && !backgroundNineSlice_ && backgroundStretch_ == stretch && + backgroundOpacity_ == clamped) { return; } backgroundImage_ = std::move(filePath); + backgroundNineSlice_ = false; backgroundStretch_ = stretch; backgroundOpacity_ = clamped; InvalidateVisual(); } +void Card::SetBackgroundImageNineSlice(std::wstring filePath, float left, float top, float right, float bottom, + float opacity) +{ + const float clamped = std::clamp(opacity, 0.0f, 1.0f); + const float l = (std::max)(0.0f, left); + const float t = (std::max)(0.0f, top); + const float r = (std::max)(0.0f, right); + const float b = (std::max)(0.0f, bottom); + if (backgroundImage_ == filePath && backgroundNineSlice_ && sliceLeft_ == l && sliceTop_ == t && sliceRight_ == r && + sliceBottom_ == b && backgroundOpacity_ == clamped) + { + return; + } + backgroundImage_ = std::move(filePath); + backgroundNineSlice_ = true; + sliceLeft_ = l; + sliceTop_ = t; + sliceRight_ = r; + sliceBottom_ = b; + backgroundOpacity_ = clamped; + InvalidateVisual(); +} + void Card::RenderBackgroundImage(DeviceResources &deviceResources, ID2D1RenderTarget *target, const D2D1_ROUNDED_RECT &shape) { @@ -2019,6 +2060,44 @@ void Card::RenderBackgroundImage(DeviceResources &deviceResources, ID2D1RenderTa return; } target->PushLayer(D2D1::LayerParameters(D2D1::InfiniteRect(), clip.Get()), layer.Get()); + if (backgroundNineSlice_) + { + // 九宫格:四角原样、四边单向拉伸、中心双向拉伸。切片单位与源图像素一致, + // 图像按 96 DPI 绘制时 1 源像素 = 1 DIP。 + const float srcW = sourceSize.width; + const float srcH = sourceSize.height; + const float l = (std::min)(sliceLeft_, srcW); + const float t = (std::min)(sliceTop_, srcH); + const float r = (std::min)(sliceRight_, srcW - l); + const float b = (std::min)(sliceBottom_, srcH - t); + const float dl = (std::min)(l, bounds_.width); + const float dt = (std::min)(t, bounds_.height); + const float dr = (std::min)(r, bounds_.width - dl); + const float db = (std::min)(b, bounds_.height - dt); + const float sx[3] = {0.0f, l, srcW - r}; + const float sw[3] = {l, srcW - l - r, r}; + const float dx[3] = {bounds_.x, bounds_.x + dl, bounds_.x + bounds_.width - dr}; + const float dw[3] = {dl, bounds_.width - dl - dr, dr}; + const float sy[3] = {0.0f, t, srcH - b}; + const float sh[3] = {t, srcH - t - b, b}; + const float dy[3] = {bounds_.y, bounds_.y + dt, bounds_.y + bounds_.height - db}; + const float dh[3] = {dt, bounds_.height - dt - db, db}; + for (int row = 0; row < 3; ++row) + { + for (int col = 0; col < 3; ++col) + { + if (sw[col] <= 0.0f || sh[row] <= 0.0f || dw[col] <= 0.0f || dh[row] <= 0.0f) + { + continue; + } + target->DrawBitmap(bitmap, D2D1::RectF(dx[col], dy[row], dx[col] + dw[col], dy[row] + dh[row]), + backgroundOpacity_, D2D1_BITMAP_INTERPOLATION_MODE_LINEAR, + D2D1::RectF(sx[col], sy[row], sx[col] + sw[col], sy[row] + sh[row])); + } + } + target->PopLayer(); + return; + } target->DrawBitmap(bitmap, D2D1::RectF(destination.x, destination.y, destination.x + destination.width, destination.y + destination.height),