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