Merge pull request #903 from Tokisaki-Galaxy/feat/skin-schema-v2

feat(server): 外部皮肤新增 schema_version 2,WebView2 与 D2D 双后端支持九宫格贴图与自由挂件
This commit is contained in:
時崎 ギャラクシー authored and GitHub committed 2026-10-13 00:57:14 +08:00
1 parent 242f8b2bc8
commit bcbbdcf74c
17 files changed
+1012 -36

No files matched your search

+40 -2
View File
@@ -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`
@@ -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)
+2
View File
@@ -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"
+11
View File
@@ -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 侧由卡片内边距承担,不需要再扣边框。
+177 -2
View File
@@ -330,12 +330,14 @@ std::optional<Package> 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<int>(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<Package> 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<double>();
if (!num || *num < 0.0 || *num > 500.0)
{
SetError(error, "candidate_window.border_image.slice_dip 超出范围");
return std::nullopt;
}
bi.sliceDip.push_back(*num);
}
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<double>();
if (!num || *num < 0.0 || *num > 500.0)
{
SetError(error, "candidate_window.border_image.stretch_dip 超出范围");
return std::nullopt;
}
bi.stretchDip.push_back(*num);
}
}
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<double>();
if (!num || *num < 0.0 || *num > 500.0)
{
SetError(error, "candidate_window.border_image.width_dip 超出范围");
return std::nullopt;
}
bi.widthDip.push_back(*num);
}
}
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<int>((*decTable)["z_index"].value_or(int64_t{2}));
if (dec.widthDip <= 0.0 || dec.heightDip <= 0.0)
{
SetError(error, "candidate_window.decorations 的 width_dip 与 height_dip 必须大于 0");
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<Package> 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 &)
+47
View File
@@ -57,8 +57,44 @@ struct ToolbarColors
std::string hover;
};
struct BorderImage
{
std::string source;
// 四边切片大小(DIP):top, right, bottom, left
std::vector<double> sliceDip;
// 可拉伸区(上右下左,从面板外缘量起):给出时覆盖 sliceDip。
// 面板图里画死的装饰必须落在可拉伸区之外,否则会被横向拉长。
std::vector<double> stretchDip;
// 四边宽度(DIP):top, right, bottom, left;若为空则沿用 sliceDip
std::vector<double> 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<double> toolbarCornerRadiusDip;
ToolbarColors toolbarDark;
ToolbarColors toolbarLight;
// v2 专有特性:九宫格贴图边框
std::optional<BorderImage> borderImage;
// v2 专有特性:面板内容/文字安全内边距,从面板外缘量起(渲染时扣掉边框宽度)。
std::optional<InsetsDip> contentInsetsDip;
// v2 专有特性:预编辑行与候选行之间的行距(DIP,0–100);未设置时沿用 base 的行距。
std::optional<double> lineGapDip;
// v2 专有特性:面板之外需要额外预留的窗口空间(上右下左 DIP),给挂在卡片外沿的挂件用。
// 不声明时窗口只按卡片大小开,挂到外面的立绘会被窗口边界裁掉。
std::optional<InsetsDip> outerInsetsDip;
// v2 专有特性:多层自由锚定挂件(支持动图)
std::vector<FreeDecoration> freeDecorations;
};
struct Issue
+5
View File
@@ -75,6 +75,11 @@ bool ApplyConfiguredFloatingToolbarAppearance(std::function<void()> 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
+189 -1
View File
@@ -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<double> &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<double> &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<double> &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<double> &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"<script id=\"external-candidate-skin-decor\">(function(){"
L"function add(){var parent=document.getElementById('realContainerParent');if(!parent){return;}"
L"[" +
indices +
L"].forEach(function(i){if(parent.querySelector('.msime-skin-decor[data-i=\"'+i+'\"]')){return;}"
L"var node=document.createElement('div');node.className='msime-skin-decor';"
L"node.setAttribute('data-i',String(i));parent.appendChild(node);});}"
L"if(document.readyState==='loading'){document.addEventListener('DOMContentLoaded',add);}else{add();}"
L"})();</script>";
}
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;
}
+97 -5
View File
@@ -8,6 +8,7 @@
#include "ipc/ipc.h"
#include "log/candidate_diag_log.h"
#include "skin/candidate_skin_catalog.h"
#include "window/candidate_skin_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<msimeui::StackPanel> root;
std::shared_ptr<msimeui::Image> decoration;
std::shared_ptr<msimeui::Card> card;
// 卡片与 v2 挂件层放在同一格里:挂件层测量为 0,格子仍由卡片决定,挂件按 anchor 画到卡片外沿。
std::shared_ptr<msimeui::Grid> overlayGrid;
std::shared_ptr<CandidateSkinOverlay> skinOverlay;
// 装饰图与卡片同列:列宽即卡片宽,装饰图在列内按 align 对齐,对应 CSS 里
// .containerParent 为 fit-content、::before 相对它定位。
std::shared_ptr<msimeui::StackPanel> 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<msimeui::Grid>();
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<CandidateSkinOverlay>();
impl_->skinOverlay->SetHorizontalAlignment(msimeui::HorizontalAlignment::Stretch);
impl_->skinOverlay->SetVerticalAlignment(msimeui::VerticalAlignment::Stretch);
impl_->overlayGrid->AddChild(impl_->skinOverlay, 0, 0);
impl_->cardColumn = std::make_shared<msimeui::StackPanel>(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<msimeui::Container>();
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<float>(outer.left);
framePadRight += static_cast<float>(outer.right);
framePadTop += static_cast<float>(outer.top);
framePadBottom += static_cast<float>(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<CandidateSkinOverlay::Item> items;
if (package)
{
for (const CandidateSkinCatalog::FreeDecoration &dec : package->freeDecorations)
{
CandidateSkinOverlay::Item item;
item.path = packageRoot + string_to_wstring(dec.image);
item.anchor = dec.anchor;
item.offsetXDip = static_cast<float>(dec.offsetXDip);
item.offsetYDip = static_cast<float>(dec.offsetYDip);
item.widthDip = static_cast<float>(dec.widthDip);
item.heightDip = static_cast<float>(dec.heightDip);
items.push_back(std::move(item));
}
}
impl_->skinOverlay->SetItems(std::move(items));
}
// v2 的 line_gap_dip 落到预编辑行与候选列表之间的间距上。
// TODO(v2 皮肤): 暂定 WebView2 独占。SetSpacing 作用于 body 的全部子元素,竖排时列表与翻页栏之间也会被拉开,
// WebView2 只加在 .row.pinyin 下方。
if (impl_->body)
{
impl_->body->SetSpacing(package && package->lineGapDip ? static_cast<float>(*package->lineGapDip) : 2.0f);
}
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<float>(insets.left), static_cast<float>(insets.top),
static_cast<float>(insets.right),
static_cast<float>(insets.bottom)});
}
else
{
impl_->card->SetPadding(tokens.containerPad);
}
impl_->card->SetMinWidth(cardMinWidthDip_);
impl_->card->SetBackgroundImage(backgroundPath, BackgroundStretch(package ? package->backgroundFit : ""),
package ? static_cast<float>(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<double> &values = bi.stretchDip.empty() ? bi.sliceDip : bi.stretchDip;
const auto expand4 = [](const std::vector<double> &v, float out[4]) {
if (v.size() == 1)
out[0] = out[1] = out[2] = out[3] = static_cast<float>(v[0]);
else if (v.size() == 2)
{
out[0] = out[2] = static_cast<float>(v[0]);
out[1] = out[3] = static_cast<float>(v[1]);
}
else
{
for (size_t i = 0; i < 4; ++i)
out[i] = i < v.size() ? static_cast<float>(v[i]) : 0.0f;
}
};
float slice[4] = {0.0f, 0.0f, 0.0f, 0.0f};
expand4(values, slice);
impl_->card->SetBackgroundImageNineSlice(packageRoot + string_to_wstring(bi.source), slice[3], slice[0],
slice[1], slice[2], 1.0f);
}
else
{
impl_->card->SetBackgroundImage(backgroundPath, BackgroundStretch(package ? package->backgroundFit : ""),
package ? static_cast<float>(package->backgroundOpacity) : 1.0f);
}
}
}
@@ -0,0 +1,102 @@
#include "window/candidate_skin_overlay.h"
#include "msimeui/DeviceResources.h"
#include <algorithm>
void CandidateSkinOverlay::SetItems(std::vector<Item> 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);
}
}
@@ -0,0 +1,40 @@
#pragma once
#include "msimeui/Layout.h"
#include <string>
#include <vector>
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<Item> items);
msimeui::SizeF Measure(const msimeui::SizeF &availableSize) override;
void Arrange(const msimeui::RectF &finalRect) override;
void Render(msimeui::DeviceResources &deviceResources) override;
// 挂件纯装饰,叠在卡片那一格上方,点击必须穿透给候选。
bool HitTest(const msimeui::PointF &point) const override;
private:
std::vector<Item> items_;
};
@@ -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<double, double> AddCandidateDecorationToSize(const std::pair<double, double> &cardSize)
{
const double decorationTopDip = GetCandidateDecorationTopDip();
if (decorationTopDip <= 0.0)
std::pair<double, double> 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<double>(packingTopDip) - GetCandidateDecorationTopDip();
const double outerOffsetDip =
static_cast<double>(packingTopDip) - GetCandidateDecorationTopDip() - GetCandidateOuterTopDip();
return cardAnchorY + static_cast<int>(std::lround(outerOffsetDip * static_cast<double>(scale)));
}
int GetCandidateOuterLeftPx(int cardAnchorX, FLOAT scale)
{
if (scale <= 0.0f)
{
scale = 1.0f;
}
return cardAnchorX - static_cast<int>(std::lround(GetCandidateOuterLeftDip() * static_cast<double>(scale)));
}
void RememberCandidateClip(const std::pair<double, double> &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<int>(std::lround((anchorPos->first - hostX) / static_cast<double>(scale))));
(std::max)(0, static_cast<int>(std::lround((desiredOuterLeftPx - hostX) / static_cast<double>(scale))));
Global::MarginTop = GetCandidateOuterMarginDip(desiredOuterTopPx, hostY, scale);
}
@@ -188,7 +188,8 @@ void PlaceCandidateHostNearCaret(HWND hwnd, std::pair<double, double> 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<int>(std::lround(::CANDIDATE_SHADOW_PAD_LEFT * static_cast<double>(layoutScale)));
const int packingMarginTop = GetCandidatePackingMarginTopDip();
int desiredOuterTopPx = GetCandidateOuterTopPx(properPos->second, packingMarginTop, layoutScale);
@@ -212,7 +213,8 @@ void PlaceCandidateHostNearCaret(HWND hwnd, std::pair<double, double> requestedC
hostY = coordinates.top + edgePadPx;
}
const int offsetXDip = static_cast<int>(std::lround((properPos->first - hostX) / static_cast<double>(layoutScale)));
const int offsetXDip =
static_cast<int>(std::lround((desiredOuterLeftPx - hostX) / static_cast<double>(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<int>(std::lround(::CANDIDATE_SHADOW_PAD_LEFT * static_cast<double>(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<int>(std::lround((properPos->first - hostX) / static_cast<double>(marginScale)));
static_cast<int>(std::lround((desiredOuterLeftPx - hostX) / static_cast<double>(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<int>(std::lround(::CANDIDATE_SHADOW_PAD_LEFT * static_cast<double>(layoutScale)));
desiredOuterTopPx = GetCandidateOuterTopPx(properPos->second, resyncPackingMarginTop, layoutScale);
hostY = desiredOuterTopPx -
@@ -718,7 +722,7 @@ int FineTuneWindow(HWND hwnd)
}
const int offsetXDip =
static_cast<int>(std::lround((properPos->first - hostX) / static_cast<double>(layoutScale)));
static_cast<int>(std::lround((desiredOuterLeftPx - hostX) / static_cast<double>(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<int>(std::lround(::CANDIDATE_SHADOW_PAD_LEFT *
static_cast<double>(pass2Scale)));
const int desiredOuterLeftPx = GetCandidateOuterLeftPx(properPos->first, pass2Scale);
int hostX = desiredOuterLeftPx - static_cast<int>(std::lround(::CANDIDATE_SHADOW_PAD_LEFT *
static_cast<double>(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<int>(std::lround((properPos->first - hostX) / static_cast<double>(pass2Scale)));
const int offsetXDip = static_cast<int>(
std::lround((desiredOuterLeftPx - hostX) / static_cast<double>(pass2Scale)));
Global::MarginLeft = (std::max)(0, offsetXDip);
Global::MarginTop = GetCandidateOuterMarginDip(desiredOuterTopPx, hostY, pass2Scale);
KeepCandidateCardInsideHostAndMonitor(hostX, hostY, hostWidthPx, hostHeightPx,
+3
View File
@@ -109,6 +109,9 @@ double GetCandidateDecorationTopDip();
int GetCandidatePackingMarginTopDip();
std::pair<double, double> AddCandidateDecorationToSize(const std::pair<double, double> &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<double, double> &containerSize, FLOAT scale,
double extraTopDip = 0.0);
void ClearCandidateWindowRegion(HWND hwnd);
@@ -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<size_t>(4));
REQUIRE_EQ(package->borderImage->sliceDip[1], 44.0);
// stretch_dip 与 slice_dip 分开存:前者才是可拉伸区,渲染时优先用它。
REQUIRE_EQ(package->borderImage->stretchDip.size(), static_cast<size_t>(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<size_t>(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<size_t>(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);
}
+13 -1
View File
@@ -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<ShadowPass> shadowPasses_;
+88 -9
View File
@@ -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),