1915 lines
83 KiB
C++
1915 lines
83 KiB
C++
#include "window/candidate_presenter.h"
|
||
|
||
#include "config/ime_config.h"
|
||
#include "defines/defines.h"
|
||
#include "defines/globals.h"
|
||
#include "global/globals.h"
|
||
#include "ipc/candidate_translation_policy.h"
|
||
#include "ipc/ipc.h"
|
||
#include "log/candidate_diag_log.h"
|
||
#include "skin/candidate_skin_catalog.h"
|
||
#include "window/candidate_skin_overlay.h"
|
||
#include "utils/common_utils.h"
|
||
#include "utils/ime_utils.h"
|
||
#include "utils/window_utils.h"
|
||
#include "webview2/windows_webview2.h"
|
||
#include "window/candidate_skin_palette.h"
|
||
#include "window/candidate_wheel_paging.h"
|
||
#include "window/ime_windows.h"
|
||
|
||
#include "msimeui/Controls.h"
|
||
#include "msimeui/DeviceResources.h"
|
||
#include "msimeui/Layout.h"
|
||
#include "msimeui/Scene.h"
|
||
#include "msimeui/Theme.h"
|
||
#include "msimeui/Window.h"
|
||
|
||
#include "engine/common/helpcode_utils.h"
|
||
#include "engine/core/scheme_type.h"
|
||
#include "engine/core/word_item.h"
|
||
|
||
#include <d2d1.h>
|
||
#include <dwmapi.h>
|
||
#include <windowsx.h>
|
||
|
||
#include <algorithm>
|
||
#include <atomic>
|
||
#include <cmath>
|
||
#include <cctype>
|
||
#include <filesystem>
|
||
#include <fstream>
|
||
#include <functional>
|
||
#include <optional>
|
||
#include <sstream>
|
||
#include <string>
|
||
#include <vector>
|
||
|
||
namespace
|
||
{
|
||
D2D1_COLOR_F ColorFromRgb(UINT rgb, float alpha = 1.0f)
|
||
{
|
||
return CandidateColorFromRgb(rgb, alpha);
|
||
}
|
||
|
||
D2D1_COLOR_F ParseCssColor(const std::string &text, D2D1_COLOR_F fallback)
|
||
{
|
||
return ParseCandidateCssColor(text, fallback);
|
||
}
|
||
|
||
int AlignHostPixels(int value)
|
||
{
|
||
constexpr int kBucket = 64;
|
||
if (value < 1)
|
||
{
|
||
value = 1;
|
||
}
|
||
return ((value + kBucket - 1) / kBucket) * kBucket;
|
||
}
|
||
|
||
// Recompute half-screen DIP limits against `scale` when it differs from the
|
||
// monitor-derived scale, so clamping and window sizing stay on one scale
|
||
// (mirrors the webview path's rasterization-scale composition).
|
||
HalfScreenDipLimits ApplyScaleToHalfScreenLimits(POINT pt, FLOAT scale)
|
||
{
|
||
HalfScreenDipLimits limits = QueryHalfScreenDipLimitsForPoint(pt);
|
||
if (scale > 0.0f)
|
||
{
|
||
const double monitorWidthPx = static_cast<double>((std::max)(1, limits.monitor.right - limits.monitor.left));
|
||
const double monitorHeightPx = static_cast<double>((std::max)(1, limits.monitor.bottom - limits.monitor.top));
|
||
limits.scale = scale;
|
||
limits.maxWidthDip = (monitorWidthPx * 0.5) / static_cast<double>(scale);
|
||
limits.maxHeightDip = (monitorHeightPx * 0.5) / static_cast<double>(scale);
|
||
}
|
||
return limits;
|
||
}
|
||
|
||
struct CandSkinTokens
|
||
{
|
||
D2D1_COLOR_F surface{};
|
||
D2D1_COLOR_F border{};
|
||
D2D1_COLOR_F text{};
|
||
D2D1_COLOR_F number = ParseCssColor("#e9e8e89d", ColorFromRgb(0xE9E8E8, 0.616f));
|
||
D2D1_COLOR_F selected = ParseCssColor("#3e3e3eb9", ColorFromRgb(0x3E3E3E, 0.725f));
|
||
D2D1_COLOR_F hover = ColorFromRgb(0x414141);
|
||
D2D1_COLOR_F accent = ColorFromRgb(0x6B69D6);
|
||
// Alpha 0: follow the candidate text at the CSS opacity .62 (see CandidateList::Appearance).
|
||
D2D1_COLOR_F translation = D2D1::ColorF(0, 0.0f);
|
||
float radius = 6.0f;
|
||
float borderWidth = 1.5f;
|
||
float containerPad = 5.0f;
|
||
bool showSelectedBar = true;
|
||
// Row corner radius, selected-row text colors and context-menu palette.
|
||
// Defaults are fluent dark; the fluent light branch and per-skin branches
|
||
// override. rowTextSelected/rowLabelSelected alpha 0 keeps the normal
|
||
// colors (bar-style selection, like the fluent CSS).
|
||
float itemRadius = 4.0f;
|
||
// > 0: rows touching the card's corners take this radius there instead.
|
||
float outerItemRadius = 0.0f;
|
||
// Row box geometry. Defaults suit the inset-highlight skins; full-bleed
|
||
// skins (willow green) pad the rows themselves because the card does not.
|
||
float itemGap = 2.0f;
|
||
float itemExtraHeight = 2.0f;
|
||
float itemPadLeft = 0.0f;
|
||
float itemPadRight = 0.0f;
|
||
// 横排时把卡片多出的宽度均分给同一行各项,铺满高亮的皮肤(杨柳青)末项才能贴到卡片右边。
|
||
bool justifyRows = false;
|
||
// 序号字号相对候选字号的比例、序号与候选之间的空、横排每项的最小宽度(0 为不限)。
|
||
float labelScale = 0.8f;
|
||
float labelGap = 1.5f;
|
||
float minItemWidth = 0.0f;
|
||
// 选中条的尺寸;selectedBarInside 让它落在高亮左缘之内,而不是骑在左缘上。
|
||
float selectedBarWidth = 3.0f;
|
||
float selectedBarHeightEm = 0.85f;
|
||
bool selectedBarInside = false;
|
||
// 翻页箭头画成候选文字色的实心三角(微软拼音那样),横排时前面再加一条分隔线。
|
||
bool trianglePager = false;
|
||
// 预编辑行隐藏时抵掉它与列表之间的 2px 行距,让卡片上下内边距一致。贴角皮肤总是这样做。
|
||
bool trimHiddenPreeditGap = false;
|
||
D2D1_COLOR_F pagerDivider = D2D1::ColorF(0, 0.0f);
|
||
msimeui::Thickness preeditMargin{};
|
||
D2D1_COLOR_F rowTextSelected = D2D1::ColorF(0, 0.0f);
|
||
D2D1_COLOR_F rowLabelSelected = D2D1::ColorF(0, 0.0f);
|
||
D2D1_COLOR_F menuFill = D2D1::ColorF(0x2D2D2D);
|
||
D2D1_COLOR_F menuBorder = ParseCssColor("#9b9b9b2e", D2D1::ColorF(0x3A3A3A, 0.18f));
|
||
D2D1_COLOR_F menuText = D2D1::ColorF(0xE9E8E8);
|
||
D2D1_COLOR_F menuHover = ColorFromRgb(0x414141);
|
||
// 外部皮肤的细分配色。alpha 0 表示没写:光标与选中条跟 accent,选中行翻译跟 translation,
|
||
// 预编辑背景与分隔线不画。
|
||
D2D1_COLOR_F preeditCaret = D2D1::ColorF(0, 0.0f);
|
||
D2D1_COLOR_F selectedBar = D2D1::ColorF(0, 0.0f);
|
||
D2D1_COLOR_F rowTranslationSelected = D2D1::ColorF(0, 0.0f);
|
||
// 翻译里的词性与分隔符。alpha 0 表示与本行翻译同色。
|
||
D2D1_COLOR_F translationPos = D2D1::ColorF(0, 0.0f);
|
||
D2D1_COLOR_F translationSeparator = D2D1::ColorF(0, 0.0f);
|
||
D2D1_COLOR_F rowTranslationPosSelected = D2D1::ColorF(0, 0.0f);
|
||
D2D1_COLOR_F rowTranslationSeparatorSelected = D2D1::ColorF(0, 0.0f);
|
||
D2D1_COLOR_F preeditBackground = D2D1::ColorF(0, 0.0f);
|
||
D2D1_COLOR_F preeditDivider = D2D1::ColorF(0, 0.0f);
|
||
};
|
||
|
||
// 内置皮肤翻译里词性与分隔符的不透明度,对应 CSS 里 8 位十六进制颜色的 99 与 73。
|
||
constexpr float kTranslationPosAlpha = 0x80 / 255.0f;
|
||
constexpr float kTranslationSeparatorAlpha = 0x73 / 255.0f;
|
||
|
||
// 内置皮肤的翻译配色取自 Notion 调色板(https://docs.super.so/notion-colors),与
|
||
// ui-html/webview2/candwnd/skins/<skin>/*.css 里的 .cand-translation 同值。词性用相邻的另一个色相再调淡,
|
||
// 和英文单词区分开;分隔符是单词色降不透明度。
|
||
void SetTranslationColors(CandSkinTokens &tokens, UINT rgb, UINT posRgb)
|
||
{
|
||
tokens.translation = ColorFromRgb(rgb);
|
||
tokens.translationPos = ColorFromRgb(posRgb, kTranslationPosAlpha);
|
||
tokens.translationSeparator = ColorFromRgb(rgb, kTranslationSeparatorAlpha);
|
||
}
|
||
|
||
// 选中行铺了实色高亮的皮肤,翻译在选中行另换一组颜色,避免彩色字压在高亮色上。
|
||
// 词性的不透明度单独给:实色高亮上按普通行的比例调淡会糊进底色。
|
||
void SetSelectedTranslationColors(CandSkinTokens &tokens, UINT rgb, float alpha, UINT posRgb, float posAlpha)
|
||
{
|
||
tokens.rowTranslationSelected = ColorFromRgb(rgb, alpha);
|
||
tokens.rowTranslationPosSelected = ColorFromRgb(posRgb, posAlpha);
|
||
tokens.rowTranslationSeparatorSelected = ColorFromRgb(rgb, alpha * kTranslationSeparatorAlpha);
|
||
}
|
||
|
||
// 皮肤阴影档位只缩放两层阴影的不透明度,几何不变;none 由调用方直接关掉阴影。
|
||
float ShadowAlphaScale(const std::string &shadow)
|
||
{
|
||
if (shadow == "soft")
|
||
return 0.5f;
|
||
if (shadow == "strong")
|
||
return 1.6f;
|
||
return 1.0f;
|
||
}
|
||
|
||
void ApplyPackageColors(const CandidateSkinCatalog::CandidateColors &colors, CandSkinTokens &tokens)
|
||
{
|
||
const auto apply = [](const std::string &value, D2D1_COLOR_F &out) {
|
||
if (!value.empty())
|
||
out = ParseCssColor(value, out);
|
||
};
|
||
apply(colors.preeditCaret, tokens.preeditCaret);
|
||
apply(colors.selectedBar, tokens.selectedBar);
|
||
apply(colors.selectedText, tokens.rowTextSelected);
|
||
apply(colors.selectedNumber, tokens.rowLabelSelected);
|
||
// 包换了翻译色就不再用基础皮肤的词性/分隔符配色:普通行回到与翻译同色,选中行跟随 selected_translation。
|
||
// 与 WebView2 端的 `color: inherit` 重置一致。
|
||
if (!colors.translation.empty())
|
||
{
|
||
tokens.translationPos = D2D1::ColorF(0, 0.0f);
|
||
tokens.translationSeparator = D2D1::ColorF(0, 0.0f);
|
||
}
|
||
if (!colors.selectedTranslation.empty())
|
||
{
|
||
apply(colors.selectedTranslation, tokens.rowTranslationSelected);
|
||
tokens.rowTranslationPosSelected = tokens.rowTranslationSelected;
|
||
tokens.rowTranslationSeparatorSelected = tokens.rowTranslationSelected;
|
||
}
|
||
apply(colors.translationPos, tokens.translationPos);
|
||
apply(colors.translationSeparator, tokens.translationSeparator);
|
||
apply(colors.selectedTranslationPos, tokens.rowTranslationPosSelected);
|
||
apply(colors.selectedTranslationSeparator, tokens.rowTranslationSeparatorSelected);
|
||
apply(colors.preeditBackground, tokens.preeditBackground);
|
||
apply(colors.preeditDivider, tokens.preeditDivider);
|
||
apply(colors.menuBackground, tokens.menuFill);
|
||
apply(colors.menuBorder, tokens.menuBorder);
|
||
apply(colors.menuText, tokens.menuText);
|
||
apply(colors.menuHover, tokens.menuHover);
|
||
if (!colors.accent.empty())
|
||
{
|
||
tokens.accent = ParseCssColor(colors.accent, tokens.accent);
|
||
}
|
||
if (!colors.selected.empty())
|
||
{
|
||
tokens.selected = ParseCssColor(colors.selected, tokens.selected);
|
||
}
|
||
if (!colors.hover.empty())
|
||
{
|
||
tokens.hover = ParseCssColor(colors.hover, tokens.hover);
|
||
}
|
||
if (!colors.number.empty())
|
||
{
|
||
tokens.number = ParseCssColor(colors.number, tokens.number);
|
||
}
|
||
if (!colors.translation.empty())
|
||
{
|
||
tokens.translation = ParseCssColor(colors.translation, tokens.translation);
|
||
}
|
||
if (colors.showSelectedBar.has_value())
|
||
{
|
||
tokens.showSelectedBar = *colors.showSelectedBar;
|
||
}
|
||
}
|
||
|
||
std::wstring AssetRoot()
|
||
{
|
||
return CommonUtils::get_ime_data_path_w();
|
||
}
|
||
|
||
constexpr float kShadowPadLeft = 32.0f;
|
||
constexpr float kShadowPadTop = 20.0f;
|
||
constexpr float kShadowPadRight = 32.0f;
|
||
constexpr float kShadowPadBottom = 40.0f;
|
||
constexpr float kCandidateMinWidthDip = 160.0f;
|
||
|
||
msimeui::ImageStretch BackgroundStretch(const std::string &fit)
|
||
{
|
||
if (fit == "contain")
|
||
return msimeui::ImageStretch::Uniform;
|
||
if (fit == "stretch")
|
||
return msimeui::ImageStretch::Fill;
|
||
return msimeui::ImageStretch::UniformToFill;
|
||
}
|
||
|
||
msimeui::HorizontalAlignment DecorationAlignment(const std::string &align)
|
||
{
|
||
if (align == "left")
|
||
return msimeui::HorizontalAlignment::Leading;
|
||
if (align == "center")
|
||
return msimeui::HorizontalAlignment::Center;
|
||
return msimeui::HorizontalAlignment::Trailing;
|
||
}
|
||
// Below this fraction of the kept extent, the page really got smaller (fewer or
|
||
// shorter candidates) and the card follows it; above it, the difference is a
|
||
// late addition coming or going and the card keeps its size. Mirrors
|
||
// kStickyShrinkRatio in the WebView2 candidate script.
|
||
constexpr float kStickyCardShrinkRatio = 0.7f;
|
||
|
||
float StickyCardExtent(float kept, float natural)
|
||
{
|
||
return natural >= kept || natural < kept * kStickyCardShrinkRatio ? natural : kept;
|
||
}
|
||
|
||
// 横排且皮肤开了翻页箭头时的候选行:列表占去箭头以外的全部宽度,箭头贴在最右侧,
|
||
// 相当于在候选右边多出一列窄列;箭头的竖直位置由它自己的外边距对齐到最后一行。
|
||
class CandidateRowWithPager : public msimeui::Panel
|
||
{
|
||
public:
|
||
CandidateRowWithPager(std::shared_ptr<msimeui::Visual> list, std::shared_ptr<msimeui::Visual> pager)
|
||
: list_(list.get()), pager_(pager.get())
|
||
{
|
||
AddChild(std::move(list));
|
||
AddChild(std::move(pager));
|
||
}
|
||
|
||
msimeui::SizeF Measure(const msimeui::SizeF &availableSize) override
|
||
{
|
||
const msimeui::SizeF pager = pager_->MeasureInLayout(availableSize);
|
||
const msimeui::SizeF list =
|
||
list_->MeasureInLayout({(std::max)(availableSize.width - pager.width, 1.0f), availableSize.height});
|
||
pagerWidth_ = pager.width;
|
||
return {list.width + pager.width, (std::max)(list.height, pager.height)};
|
||
}
|
||
|
||
void Arrange(const msimeui::RectF &finalRect) override
|
||
{
|
||
bounds_ = finalRect;
|
||
const float listWidth = (std::max)(finalRect.width - pagerWidth_, 0.0f);
|
||
list_->ArrangeInLayout({finalRect.x, finalRect.y, listWidth, finalRect.height});
|
||
pager_->ArrangeInLayout({finalRect.x + listWidth, finalRect.y, pagerWidth_, finalRect.height});
|
||
}
|
||
|
||
void Render(msimeui::DeviceResources &deviceResources) override
|
||
{
|
||
for (const auto &child : children_)
|
||
{
|
||
child->Render(deviceResources);
|
||
}
|
||
}
|
||
|
||
private:
|
||
msimeui::Visual *list_ = nullptr;
|
||
msimeui::Visual *pager_ = nullptr;
|
||
float pagerWidth_ = 0.0f;
|
||
};
|
||
|
||
D2D1_COLOR_F WithAlpha(D2D1_COLOR_F color, float alpha)
|
||
{
|
||
color.a *= alpha;
|
||
return color;
|
||
}
|
||
|
||
} // namespace
|
||
|
||
struct CandidatePresenter::Impl
|
||
{
|
||
msimeui::DeviceResources resources;
|
||
std::unique_ptr<msimeui::Window> window;
|
||
std::shared_ptr<msimeui::StackPanel> root;
|
||
std::shared_ptr<msimeui::Image> decoration;
|
||
std::shared_ptr<msimeui::Card> card;
|
||
// 卡片与 v2 挂件层放在同一格里:挂件层测量为 0,格子仍由卡片决定,挂件按 anchor 画到卡片外沿。
|
||
std::shared_ptr<msimeui::Grid> overlayGrid;
|
||
std::shared_ptr<CandidateSkinOverlay> skinOverlay;
|
||
// 装饰图与卡片同列:列宽即卡片宽,装饰图在列内按 align 对齐,对应 CSS 里
|
||
// .containerParent 为 fit-content、::before 相对它定位。
|
||
std::shared_ptr<msimeui::StackPanel> cardColumn;
|
||
std::shared_ptr<msimeui::Container> frame;
|
||
std::shared_ptr<msimeui::StackPanel> body;
|
||
std::shared_ptr<msimeui::TextBlock> preedit;
|
||
std::shared_ptr<msimeui::CandidateList> list;
|
||
// 皮肤关掉翻页箭头时为空。场景按 sceneHorizontal / scenePageArrows 搭建,两者变了就重建。
|
||
std::shared_ptr<msimeui::PagerArrows> pager;
|
||
bool sceneHorizontal = false;
|
||
bool scenePageArrows = false;
|
||
std::shared_ptr<msimeui::Popup> contextMenu;
|
||
std::shared_ptr<msimeui::Popup> contextSubmenu;
|
||
std::shared_ptr<msimeui::MenuFlyoutItem> fixPositionItem;
|
||
D2D1_COLOR_F menuFill = D2D1::ColorF(0x2D2D2D);
|
||
D2D1_COLOR_F menuBorder = ParseCssColor("#9b9b9b2e", D2D1::ColorF(0x3A3A3A, 0.18f));
|
||
D2D1_COLOR_F menuText = D2D1::ColorF(0xE9E8E8);
|
||
D2D1_COLOR_F menuHover = D2D1::ColorF(0x414141);
|
||
RECT hostRectBeforeMenu{};
|
||
bool contextMenuOpen = false;
|
||
bool contextSubmenuOpen = false;
|
||
bool hostExpandedForMenu = false;
|
||
size_t contextMenuPageIndex = 0;
|
||
};
|
||
|
||
CandidatePresenter::CandidatePresenter() = default;
|
||
|
||
CandidatePresenter &CandidatePresenter::Instance()
|
||
{
|
||
static CandidatePresenter instance;
|
||
return instance;
|
||
}
|
||
|
||
bool CandidatePresenter::IsBound() const
|
||
{
|
||
return bound_;
|
||
}
|
||
|
||
bool CandidatePresenter::Bind(HWND hwnd)
|
||
{
|
||
hwnd_ = hwnd;
|
||
if (!hwnd)
|
||
{
|
||
bound_ = false;
|
||
impl_.reset();
|
||
return false;
|
||
}
|
||
impl_ = std::make_unique<Impl>();
|
||
impl_->window = std::make_unique<msimeui::Window>(L"metasequoiaime_windows", L"cand", 1, 1);
|
||
impl_->window->AdoptExistingHwnd(hwnd);
|
||
impl_->window->SetStealFocusOnClick(false);
|
||
bound_ = impl_->resources.EnsureForComposition(hwnd);
|
||
RebuildScene();
|
||
CAND_DIAG_LOGF(L"candidate-d2d bind hwnd={:#x} composition={}", reinterpret_cast<uintptr_t>(hwnd), bound_ ? 1 : 0);
|
||
return bound_;
|
||
}
|
||
|
||
void CandidatePresenter::RebuildScene()
|
||
{
|
||
if (!impl_ || !impl_->window)
|
||
{
|
||
return;
|
||
}
|
||
lastSkinFingerprint_.clear();
|
||
impl_->root = std::make_shared<msimeui::StackPanel>(0.0f);
|
||
impl_->decoration = std::make_shared<msimeui::Image>(L"");
|
||
impl_->decoration->SetHorizontalAlignment(msimeui::HorizontalAlignment::Trailing);
|
||
impl_->decoration->SetStretch(msimeui::ImageStretch::Uniform);
|
||
impl_->preedit = std::make_shared<msimeui::TextBlock>(L"", 14.0f, D2D1::ColorF(0xF5F5F5));
|
||
impl_->preedit->SetTextLayoutPadding({0.0f, 1.0f, 0.0f, 1.0f});
|
||
impl_->preedit->SetHorizontalAlignment(msimeui::HorizontalAlignment::Leading);
|
||
impl_->list = std::make_shared<msimeui::CandidateList>(28.0f);
|
||
impl_->list->SetOnItemActivated([this](size_t index) { CommitItem(index); });
|
||
impl_->list->SetOnContextMenu(
|
||
[this](size_t index, const POINT &clientPoint) { ShowItemContextMenu(index, clientPoint); });
|
||
impl_->body = std::make_shared<msimeui::StackPanel>(2.0f);
|
||
impl_->body->SetPadding({0.0f, 0.0f, 0.0f, 0.0f});
|
||
impl_->body->AddChild(impl_->preedit);
|
||
impl_->pager.reset();
|
||
if (impl_->scenePageArrows)
|
||
{
|
||
impl_->pager = std::make_shared<msimeui::PagerArrows>();
|
||
// 箭头在自己的槽位里贴右下角,槽位铺满即可(竖排是整行宽,横排是列表的整个高度)。
|
||
impl_->pager->SetVerticalAlignment(msimeui::VerticalAlignment::Stretch);
|
||
impl_->pager->SetOnClick([this](msimeui::PagerArrows::Part part) {
|
||
if (hwnd_)
|
||
{
|
||
PostMessageW(hwnd_, WM_PAGE_CANDIDATE_ARROW,
|
||
part == msimeui::PagerArrows::Part::Next ? CANDIDATE_PAGE_NEXT : CANDIDATE_PAGE_PREVIOUS,
|
||
1);
|
||
}
|
||
});
|
||
}
|
||
if (impl_->pager && impl_->sceneHorizontal)
|
||
{
|
||
impl_->body->AddChild(std::make_shared<CandidateRowWithPager>(impl_->list, impl_->pager));
|
||
}
|
||
else
|
||
{
|
||
// 竖排时箭头是列表下方单独的一矮行,靠右。
|
||
impl_->body->AddChild(impl_->list);
|
||
if (impl_->pager)
|
||
impl_->body->AddChild(impl_->pager);
|
||
}
|
||
msimeui::Brush brush;
|
||
brush.fill = D2D1::ColorF(0x202020);
|
||
brush.stroke = ParseCssColor("#9b9b9b2e", D2D1::ColorF(0x3A3A3A, 0.18f));
|
||
brush.strokeWidth = 1.5f;
|
||
brush.radiusX = 6.0f;
|
||
brush.radiusY = 6.0f;
|
||
impl_->card = std::make_shared<msimeui::Card>(brush, 5.0f);
|
||
impl_->card->SetHorizontalAlignment(msimeui::HorizontalAlignment::Leading);
|
||
impl_->card->AddChild(impl_->body);
|
||
impl_->root->SetHorizontalContentAlignment(msimeui::HorizontalAlignment::Leading);
|
||
impl_->overlayGrid = std::make_shared<msimeui::Grid>();
|
||
impl_->overlayGrid->AddRow(msimeui::GridLength{msimeui::GridUnitType::Auto, 1.0f});
|
||
impl_->overlayGrid->AddColumn(msimeui::GridLength{msimeui::GridUnitType::Auto, 1.0f});
|
||
impl_->overlayGrid->AddChild(impl_->card, 0, 0);
|
||
impl_->skinOverlay = std::make_shared<CandidateSkinOverlay>();
|
||
impl_->skinOverlay->SetHorizontalAlignment(msimeui::HorizontalAlignment::Stretch);
|
||
impl_->skinOverlay->SetVerticalAlignment(msimeui::VerticalAlignment::Stretch);
|
||
impl_->overlayGrid->AddChild(impl_->skinOverlay, 0, 0);
|
||
impl_->cardColumn = std::make_shared<msimeui::StackPanel>(0.0f);
|
||
impl_->cardColumn->SetHorizontalAlignment(msimeui::HorizontalAlignment::Leading);
|
||
impl_->cardColumn->SetHorizontalContentAlignment(msimeui::HorizontalAlignment::Stretch);
|
||
impl_->cardColumn->AddChild(impl_->decoration);
|
||
impl_->cardColumn->AddChild(impl_->overlayGrid);
|
||
impl_->frame = std::make_shared<msimeui::Container>();
|
||
impl_->frame->SetPadding({kShadowPadLeft, kShadowPadTop, kShadowPadRight, kShadowPadBottom});
|
||
impl_->frame->SetChild(impl_->cardColumn);
|
||
impl_->root->AddChild(impl_->frame);
|
||
auto scene = std::make_unique<msimeui::Scene>();
|
||
scene->SetRoot(impl_->root);
|
||
impl_->window->SetScene(std::move(scene));
|
||
}
|
||
|
||
void CandidatePresenter::ApplySkin()
|
||
{
|
||
if (!impl_ || !impl_->list || !impl_->preedit || !impl_->window)
|
||
{
|
||
return;
|
||
}
|
||
const std::string skinId = GetConfiguredCandidateSkin();
|
||
// Resolve the effective light/dark mode before the cache check: with
|
||
// theme_cand = "follow" the raw setting is unchanged when theme_mode or
|
||
// the Windows theme flips, but the resolved colors are what we render
|
||
// with, so the resolved mode must participate in the fingerprint.
|
||
const bool candLight = ResolveConfiguredTheme(GetConfiguredThemeCand()) == "light";
|
||
std::ostringstream fingerprint;
|
||
fingerprint << skinId << '|' << GetConfiguredThemeCand() << '|' << (candLight ? 'L' : 'D') << '|'
|
||
<< GetConfiguredCandidateFont() << '|' << GetConfiguredCandidateFontSize() << '|'
|
||
<< GetConfiguredCandidateWindowPreeditFontSize() << '|' << GetConfiguredCandidateWindowLayout() << '|'
|
||
<< GetConfiguredCandidateTextColor() << '|' << GetConfiguredCandidateWindowPreeditStyle();
|
||
fingerprint << '|' << GetConfiguredCandidateEnglishFont() << '|' << GetConfiguredCandidateWindowFitContent();
|
||
// 同一个外部皮肤的 skin.toml 或图片改了,只有强制重载能看出来;带上重载代数让 D2D 也重读 manifest。
|
||
fingerprint << '|' << GetCandidateSkinReloadRevision();
|
||
for (const auto &font : GetConfiguredCandidateFallbackFonts())
|
||
fingerprint << '|' << font.size() << ':' << font;
|
||
const std::string skinKey = fingerprint.str();
|
||
if (skinKey == lastSkinFingerprint_ && impl_->card)
|
||
{
|
||
return;
|
||
}
|
||
lastSkinFingerprint_ = skinKey;
|
||
CandSkinTokens tokens;
|
||
if (candLight)
|
||
{
|
||
tokens.number = D2D1::ColorF(26.0f / 255.0f, 26.0f / 255.0f, 26.0f / 255.0f, 0.55f);
|
||
tokens.selected = ColorFromRgb(0xE8E8E8);
|
||
tokens.hover = ColorFromRgb(0xECECEC);
|
||
tokens.menuFill = ColorFromRgb(0xFFFFFF);
|
||
tokens.menuBorder = D2D1::ColorF(0, 0.12f);
|
||
tokens.menuText = ColorFromRgb(0x1A1A1A);
|
||
tokens.menuHover = ColorFromRgb(0xECECEC);
|
||
}
|
||
const std::wstring skinsRoot = AssetRoot() + L"\\skins";
|
||
std::optional<CandidateSkinCatalog::Package> package;
|
||
if (!CandidateSkinCatalog::IsBuiltIn(skinId))
|
||
{
|
||
package = CandidateSkinCatalog::Load(std::filesystem::path(skinsRoot), skinId);
|
||
}
|
||
// 自定义包的几何取自它的 base 皮肤,与 WebView2 端注入 base 皮肤 CSS 一致;颜色随后由包覆盖。
|
||
const std::string baseSkin = package ? package->base : skinId;
|
||
// Built-in skin palettes mirror ui-html/webview2/candwnd/skins/<skin>/;
|
||
// the WebView2 CSS is the reference for both the light and dark values.
|
||
// 横排时高亮内缩于外框,首项、尾项落在卡片四角上的那几个角与卡片同 R,其余角保持
|
||
// itemRadius,对应 CSS 横排的 .pinyin + .row-wrapper / .last-visible 规则。
|
||
const bool horizontalLayout = GetConfiguredCandidateWindowLayout() == "horizontal";
|
||
// 翻页箭头改变的是场景结构(竖排多一行、横排多一列),开关或排列方式变了就整棵重建,
|
||
// 再往下套用配色与尺寸。重建会清掉指纹,这里补回去。
|
||
const bool pageArrows = CandidateSkinCatalog::ResolvePageArrows(std::filesystem::path(skinsRoot), skinId,
|
||
package ? &*package : nullptr);
|
||
if (pageArrows != impl_->scenePageArrows || horizontalLayout != impl_->sceneHorizontal)
|
||
{
|
||
impl_->scenePageArrows = pageArrows;
|
||
impl_->sceneHorizontal = horizontalLayout;
|
||
RebuildScene();
|
||
lastSkinFingerprint_ = skinKey;
|
||
}
|
||
fitContent_ = GetConfiguredCandidateWindowFitContent() ||
|
||
CandidateSkinCatalog::ResolveFitContent(std::filesystem::path(skinsRoot), skinId,
|
||
package ? &*package : nullptr);
|
||
const float fontSize = static_cast<float>((std::max)(12, GetConfiguredCandidateFontSize()));
|
||
if (baseSkin == "fluent")
|
||
{
|
||
if (horizontalLayout)
|
||
tokens.outerItemRadius = tokens.radius;
|
||
// 单词 Notion Purple,与强调色同一色相;词性 Notion Pink。
|
||
SetTranslationColors(tokens, candLight ? 0x6940A5 : 0x9A6DD7, candLight ? 0xAD1A72 : 0xE255A1);
|
||
}
|
||
else if (baseSkin == "wechat")
|
||
{
|
||
tokens.borderWidth = 1.0f;
|
||
tokens.radius = 5.0f;
|
||
tokens.containerPad = 2.0f;
|
||
if (horizontalLayout)
|
||
tokens.outerItemRadius = tokens.radius;
|
||
tokens.accent = ColorFromRgb(0x07C160);
|
||
tokens.selected = ColorFromRgb(0x07C160);
|
||
tokens.showSelectedBar = false;
|
||
tokens.rowTextSelected = ColorFromRgb(0xFFFFFF);
|
||
tokens.rowLabelSelected = ColorFromRgb(0xFFFFFF);
|
||
// 单词 Notion Blue,词性 Notion Green;选中行是实色绿底,单词换成半透明白,词性换 Notion Yellow。
|
||
SetTranslationColors(tokens, candLight ? 0x0B6E99 : 0x529CCA, candLight ? 0x0F7B6C : 0x4DAB9A);
|
||
SetSelectedTranslationColors(tokens, 0xFFFFFF, 0.8f, 0xFFDC49, 0.85f);
|
||
if (candLight)
|
||
{
|
||
tokens.hover = D2D1::ColorF(7.0f / 255.0f, 193.0f / 255.0f, 96.0f / 255.0f, 0.14f);
|
||
tokens.number = ColorFromRgb(0x757575);
|
||
tokens.menuFill = ColorFromRgb(0xFFFFFF);
|
||
tokens.menuBorder = ColorFromRgb(0xD9D9D9);
|
||
tokens.menuText = ColorFromRgb(0x333333);
|
||
tokens.menuHover = ColorFromRgb(0xEEEEEE);
|
||
}
|
||
else
|
||
{
|
||
tokens.hover = D2D1::ColorF(7.0f / 255.0f, 193.0f / 255.0f, 96.0f / 255.0f, 0.32f);
|
||
tokens.number = ColorFromRgb(0x858585);
|
||
tokens.menuFill = ColorFromRgb(0x1F1F1F);
|
||
tokens.menuBorder = ColorFromRgb(0x343434);
|
||
tokens.menuText = ColorFromRgb(0xD0D0D0);
|
||
tokens.menuHover = ColorFromRgb(0x2A2A2A);
|
||
}
|
||
}
|
||
else if (baseSkin == "willow_green")
|
||
{
|
||
tokens.borderWidth = 0.0f;
|
||
tokens.radius = 9.0f;
|
||
tokens.containerPad = 0.0f;
|
||
// 高亮整行(竖排)/整列(横排)铺满、左右贴着卡片边,行本身是直角;CSS 靠
|
||
// 容器 clip-path 裁出窗口圆角。D2D 的 Card 不裁剪子控件,所以改由列表把
|
||
// 落在卡片四角上的那几个角画成卡片的 9px,其余角保持直角。
|
||
tokens.itemRadius = 0.0f;
|
||
tokens.outerItemRadius = 9.0f;
|
||
tokens.itemGap = 0.0f;
|
||
tokens.justifyRows = true;
|
||
// 行内边距对齐 CSS 的 .row.cand padding(加上 .text 的左内边距),预编辑行对齐 .row.pinyin。
|
||
if (GetConfiguredCandidateWindowLayout() == "horizontal")
|
||
{
|
||
tokens.itemExtraHeight = 10.0f;
|
||
tokens.itemPadLeft = 6.0f;
|
||
tokens.itemPadRight = 10.0f;
|
||
tokens.preeditMargin = {3.0f, 6.0f, 5.0f, 1.0f};
|
||
}
|
||
else
|
||
{
|
||
tokens.itemExtraHeight = 8.0f;
|
||
tokens.itemPadLeft = 9.0f;
|
||
tokens.itemPadRight = 14.0f;
|
||
tokens.preeditMargin = {3.0f, 6.0f, 7.0f, 2.0f};
|
||
}
|
||
tokens.showSelectedBar = false;
|
||
tokens.rowTextSelected = ColorFromRgb(0xFFFFFF);
|
||
tokens.rowLabelSelected = ColorFromRgb(0xFFFFFF);
|
||
// 单词 Notion Green,词性 Notion Blue;选中行是实色绿底,单词换成半透明白,词性换 Notion Yellow。
|
||
SetTranslationColors(tokens, candLight ? 0x0F7B6C : 0x4DAB9A, candLight ? 0x0B6E99 : 0x529CCA);
|
||
SetSelectedTranslationColors(tokens, 0xFFFFFF, 0.8f, 0xFFDC49, 0.85f);
|
||
if (candLight)
|
||
{
|
||
tokens.accent = ColorFromRgb(0x58B980);
|
||
tokens.selected = ColorFromRgb(0x58B980);
|
||
tokens.hover = D2D1::ColorF(88.0f / 255.0f, 185.0f / 255.0f, 128.0f / 255.0f, 0.16f);
|
||
tokens.number = ColorFromRgb(0x686F6A);
|
||
tokens.menuFill = ColorFromRgb(0xFBFCFA);
|
||
tokens.menuBorder = ColorFromRgb(0xD8DED9);
|
||
tokens.menuText = ColorFromRgb(0x343936);
|
||
tokens.menuHover = ColorFromRgb(0xE9EEEA);
|
||
}
|
||
else
|
||
{
|
||
tokens.accent = ColorFromRgb(0x65C98D);
|
||
tokens.selected = ColorFromRgb(0x65C98D);
|
||
tokens.hover = D2D1::ColorF(101.0f / 255.0f, 201.0f / 255.0f, 141.0f / 255.0f, 0.22f);
|
||
tokens.number = ColorFromRgb(0xA6ABA7);
|
||
tokens.menuFill = ColorFromRgb(0x343635);
|
||
tokens.menuBorder = ColorFromRgb(0x454845);
|
||
tokens.menuText = ColorFromRgb(0xE0E2DF);
|
||
tokens.menuHover = ColorFromRgb(0x414441);
|
||
}
|
||
}
|
||
else if (baseSkin == "graphite")
|
||
{
|
||
tokens.borderWidth = 1.0f;
|
||
tokens.radius = 3.0f;
|
||
tokens.containerPad = 5.0f;
|
||
tokens.itemRadius = 2.0f;
|
||
tokens.selected = D2D1::ColorF(0, 0.0f);
|
||
tokens.showSelectedBar = false;
|
||
// 单词 Notion Brown,石墨铅笔的木杆色,压在冷灰底上不抢候选;词性 Notion Orange。
|
||
SetTranslationColors(tokens, candLight ? 0x64473A : 0x937264, candLight ? 0xD9730D : 0xFFA344);
|
||
if (candLight)
|
||
{
|
||
tokens.accent = ColorFromRgb(0x5F6B7A);
|
||
tokens.hover = D2D1::ColorF(31.0f / 255.0f, 41.0f / 255.0f, 55.0f / 255.0f, 0.055f);
|
||
tokens.number = ColorFromRgb(0x8993A1);
|
||
tokens.rowTextSelected = ColorFromRgb(0x111827);
|
||
tokens.rowLabelSelected = ColorFromRgb(0x111827);
|
||
tokens.menuFill = ColorFromRgb(0xFFFFFF);
|
||
tokens.menuBorder = ColorFromRgb(0xDFE3E8);
|
||
tokens.menuText = ColorFromRgb(0x374151);
|
||
tokens.menuHover = ColorFromRgb(0xF1F3F5);
|
||
}
|
||
else
|
||
{
|
||
tokens.accent = ColorFromRgb(0x8993A0);
|
||
tokens.hover = D2D1::ColorF(1.0f, 1.0f, 1.0f, 0.055f);
|
||
tokens.number = ColorFromRgb(0x707987);
|
||
tokens.rowTextSelected = ColorFromRgb(0xF1F3F5);
|
||
tokens.rowLabelSelected = ColorFromRgb(0xF1F3F5);
|
||
tokens.menuFill = ColorFromRgb(0x23272C);
|
||
tokens.menuBorder = ColorFromRgb(0x3A4047);
|
||
tokens.menuText = ColorFromRgb(0xC7CDD5);
|
||
tokens.menuHover = ColorFromRgb(0x30353B);
|
||
}
|
||
}
|
||
else if (baseSkin == "autumn_osmanthus")
|
||
{
|
||
tokens.borderWidth = 0.0f;
|
||
tokens.radius = 10.0f;
|
||
tokens.containerPad = 5.0f;
|
||
// 高亮块内缩在卡片里:普通角 6px,贴着卡片四角的那几个角与卡片同为 10px
|
||
// (首项顶部两角、尾项底部两角),对应 CSS 的 --ao-radius / --ao-item-radius。
|
||
tokens.itemRadius = 6.0f;
|
||
tokens.outerItemRadius = 10.0f;
|
||
tokens.showSelectedBar = false;
|
||
// 翻译:浅色单词 Notion Orange、词性 Notion Red,选中行的淡黄高亮上单词换 Notion Brown;
|
||
// 深色单词 Notion Yellow、词性 Notion Orange,选中行橙底上单词换半透明白、词性换 Notion Brown。
|
||
if (candLight)
|
||
{
|
||
SetTranslationColors(tokens, 0xD9730D, 0xE03E3E);
|
||
SetSelectedTranslationColors(tokens, 0x64473A, 1.0f, 0xE03E3E, kTranslationPosAlpha);
|
||
tokens.accent = ColorFromRgb(0xE6A817);
|
||
tokens.selected = ColorFromRgb(0xFFE399);
|
||
tokens.hover = D2D1::ColorF(255.0f / 255.0f, 227.0f / 255.0f, 153.0f / 255.0f, 0.55f);
|
||
tokens.number = ColorFromRgb(0x5B727B);
|
||
tokens.rowTextSelected = ColorFromRgb(0x1A1A1A);
|
||
tokens.rowLabelSelected = ColorFromRgb(0x1A1A1A);
|
||
tokens.menuFill = ColorFromRgb(0xE8F5F7);
|
||
tokens.menuBorder = ColorFromRgb(0xBCD8DE);
|
||
tokens.menuText = ColorFromRgb(0x1F3138);
|
||
tokens.menuHover = ColorFromRgb(0xD2E8EC);
|
||
}
|
||
else
|
||
{
|
||
SetTranslationColors(tokens, 0xFFDC49, 0xFFA344);
|
||
SetSelectedTranslationColors(tokens, 0xFFFFFF, 0.8f, 0x64473A, 0.85f);
|
||
tokens.accent = ColorFromRgb(0xF97D0A);
|
||
tokens.selected = ColorFromRgb(0xF97D0A);
|
||
tokens.hover = D2D1::ColorF(249.0f / 255.0f, 125.0f / 255.0f, 10.0f / 255.0f, 0.30f);
|
||
tokens.number = ColorFromRgb(0xE1E8EC);
|
||
tokens.rowTextSelected = ColorFromRgb(0xFFFFFF);
|
||
tokens.rowLabelSelected = ColorFromRgb(0xFFFFFF);
|
||
tokens.menuFill = ColorFromRgb(0x6F8491);
|
||
tokens.menuBorder = ColorFromRgb(0x8CA0AC);
|
||
tokens.menuText = ColorFromRgb(0xF5F8FA);
|
||
tokens.menuHover = ColorFromRgb(0x637885);
|
||
}
|
||
}
|
||
else if (baseSkin == "microsoft")
|
||
{
|
||
// 几何随字号缩放,对应 CSS 里以 em 写的尺寸(ui-html/webview2/candwnd/skins/microsoft/)。
|
||
tokens.borderWidth = 1.0f;
|
||
tokens.radius = 8.0f;
|
||
tokens.containerPad = std::round(fontSize * 0.3f);
|
||
tokens.itemRadius = 4.0f;
|
||
tokens.itemExtraHeight = std::round(fontSize * 0.47f);
|
||
// 序号左端距高亮左缘 0.76em,其中 5 DIP 是列表固定的 textPadLeft。
|
||
tokens.itemPadLeft = (std::max)(0.0f, std::round(fontSize * 0.76f) - 5.0f);
|
||
tokens.itemPadRight = std::round(fontSize * 0.9f);
|
||
if (horizontalLayout)
|
||
{
|
||
tokens.itemGap = std::round(fontSize * 0.28f);
|
||
tokens.minItemWidth = std::round(fontSize * 4.35f);
|
||
}
|
||
tokens.labelScale = 1.0f;
|
||
tokens.labelGap = std::round(fontSize * 0.2f);
|
||
tokens.selectedBarWidth = (std::max)(3.0f, fontSize * 0.24f);
|
||
tokens.selectedBarHeightEm = 1.1f;
|
||
tokens.selectedBarInside = true;
|
||
tokens.trianglePager = true;
|
||
tokens.trimHiddenPreeditGap = true;
|
||
tokens.accent = ColorFromRgb(0xE183D9);
|
||
// 单词 Notion Pink,与粉色选中条同一色相;词性 Notion Purple。
|
||
SetTranslationColors(tokens, candLight ? 0xAD1A72 : 0xE255A1, candLight ? 0x6940A5 : 0x9A6DD7);
|
||
if (candLight)
|
||
{
|
||
tokens.selected = ColorFromRgb(0xEAEAEA);
|
||
tokens.hover = ColorFromRgb(0xF0F0F0);
|
||
tokens.number = ColorFromRgb(0x5F5F5F);
|
||
tokens.pagerDivider = ColorFromRgb(0xE0E0E0);
|
||
tokens.menuFill = ColorFromRgb(0xF9F9F9);
|
||
tokens.menuBorder = D2D1::ColorF(0, 0.1f);
|
||
tokens.menuText = ColorFromRgb(0x1A1A1A);
|
||
tokens.menuHover = ColorFromRgb(0xEAEAEA);
|
||
}
|
||
else
|
||
{
|
||
tokens.selected = ColorFromRgb(0x383838);
|
||
tokens.hover = ColorFromRgb(0x353535);
|
||
tokens.number = ColorFromRgb(0xCFCFCF);
|
||
tokens.pagerDivider = ColorFromRgb(0x3D3D3D);
|
||
tokens.menuFill = ColorFromRgb(0x2C2C2C);
|
||
tokens.menuBorder = ColorFromRgb(0x1C1C1C);
|
||
tokens.menuText = ColorFromRgb(0xFFFFFF);
|
||
tokens.menuHover = ColorFromRgb(0x383838);
|
||
}
|
||
}
|
||
|
||
if (package)
|
||
{
|
||
ApplyPackageColors(candLight ? package->light : package->dark, tokens);
|
||
// 自定义圆角:外框和贴着外框四角的高亮角一起换成 R,普通高亮角不变。
|
||
if (package->cornerRadiusDip)
|
||
{
|
||
tokens.radius = static_cast<float>(*package->cornerRadiusDip);
|
||
// 贴角规则以 outerItemRadius > 0 为开关;R 为 0 时仍要保持开启,否则贴角的
|
||
// 高亮会退回普通圆角、从直角外框里探出来。
|
||
if (tokens.outerItemRadius > 0.0f)
|
||
tokens.outerItemRadius = (std::max)(tokens.radius, 0.01f);
|
||
}
|
||
if (package->borderWidthDip)
|
||
tokens.borderWidth = static_cast<float>(*package->borderWidthDip);
|
||
// 只改普通高亮角;贴着卡片四角的那几个角仍跟卡片圆角。
|
||
if (package->itemCornerRadiusDip)
|
||
tokens.itemRadius = static_cast<float>(*package->itemCornerRadiusDip);
|
||
}
|
||
const CandidateSkinCatalog::CandidateColors *packageColors =
|
||
package ? &(candLight ? package->light : package->dark) : nullptr;
|
||
const CandidateSkinPalette palette = ResolveCandidateSkinPalette(
|
||
skinId, candLight, GetConfiguredCandidateTextColor(), packageColors, package ? package->base : std::string{});
|
||
tokens.surface = palette.surface;
|
||
tokens.border = palette.border;
|
||
tokens.text = palette.text;
|
||
// 阴影跟皮肤走,与 WebView2 端同一参照:四套内置皮肤与自定义包共用的皮肤 CSS
|
||
// (ui-html/webview2/candwnd/skins/*/horizontal_*.css)带同一对 box-shadow——
|
||
// light `8px 10px 24px rgba(0,0,0,.18), 2px 3px 8px rgba(0,0,0,.10)`,dark α .34/.22;
|
||
// σ = blur ÷ 2。默认皮肤(webview 端只有 body HTML,无 box-shadow)直接关阴影。
|
||
if (impl_->card)
|
||
{
|
||
const std::string shadow = package ? package->shadow : std::string{};
|
||
if ((CandidateSkinCatalog::IsBuiltIn(skinId) || package) && shadow != "none")
|
||
{
|
||
// 外部皮肤的 shadow = soft / strong 按同一系数缩放两层不透明度,与 WebView2 端一致。
|
||
const float scale = ShadowAlphaScale(shadow);
|
||
const auto alpha = [scale](float value) { return (std::min)(value * scale, 1.0f); };
|
||
const std::vector<msimeui::ShadowPass> shadowPasses = {
|
||
{12.0f, alpha(candLight ? 0.18f : 0.34f), 8.0f, 10.0f},
|
||
{4.0f, alpha(candLight ? 0.10f : 0.22f), 2.0f, 3.0f},
|
||
};
|
||
impl_->card->SetShadowEnabled(true);
|
||
impl_->card->SetShadowPasses(shadowPasses);
|
||
}
|
||
else
|
||
{
|
||
impl_->card->SetShadowEnabled(false);
|
||
}
|
||
}
|
||
|
||
msimeui::Theme theme = msimeui::ThemeManager::GetCurrent();
|
||
theme.textInputFontFamily = string_to_wstring(GetConfiguredCandidateFont());
|
||
if (theme.textInputFontFamily.empty())
|
||
{
|
||
theme.textInputFontFamily = L"Microsoft YaHei UI";
|
||
}
|
||
theme.uiFontFamily = theme.textInputFontFamily;
|
||
theme.surface = tokens.surface;
|
||
theme.border = tokens.border;
|
||
theme.textPrimary = palette.text;
|
||
theme.textSecondary = tokens.number;
|
||
theme.primary = tokens.accent;
|
||
theme.windowBackground = D2D1::ColorF(0, 0.0f);
|
||
msimeui::ThemeManager::SetCurrent(theme);
|
||
|
||
const float preeditSize = static_cast<float>((std::max)(12, GetConfiguredCandidateWindowPreeditFontSize()));
|
||
msimeui::CandidateList::Appearance appearance;
|
||
appearance.fontFamily = string_to_wstring(ResolveSystemFontFamilyForCss(GetConfiguredCandidateEnglishFont()));
|
||
// 皮肤的 font_family 排在最前,用户配置的字体整体退为回退,与 WebView2 端的 font-family 列表同序。
|
||
if (package && !package->fontFamily.empty())
|
||
{
|
||
appearance.fallbackFontFamilies.push_back(appearance.fontFamily);
|
||
appearance.fontFamily = string_to_wstring(ResolveSystemFontFamilyForCss(package->fontFamily));
|
||
}
|
||
for (const auto &font : GetConfiguredCandidateFallbackFontFamilies())
|
||
appearance.fallbackFontFamilies.push_back(string_to_wstring(font));
|
||
appearance.itemHeight = fontSize * 1.35f + tokens.itemExtraHeight;
|
||
appearance.itemGap = tokens.itemGap;
|
||
appearance.fontSize = fontSize;
|
||
appearance.labelFontSize = fontSize * tokens.labelScale;
|
||
appearance.annotationFontSize = fontSize;
|
||
appearance.contentPadLeft = tokens.itemPadLeft;
|
||
appearance.contentPadRight = tokens.itemPadRight;
|
||
// itemExtraHeight 上下各半:横排翻译换到第二行时从下半段内边距上方开始,内边距留在翻译下面,
|
||
// 对应 CSS 里 .row.cand 的 padding 包住整个 .text(杨柳青横排上下各 6px)。
|
||
appearance.contentPadBottom = tokens.itemExtraHeight * 0.5f;
|
||
appearance.textPadLeft = 5.0f;
|
||
appearance.labelGap = tokens.labelGap;
|
||
appearance.minItemWidth = tokens.minItemWidth;
|
||
// 收紧宽度时右侧留白与序号左侧的留白相同,卡片两边对称。
|
||
appearance.fitContent = fitContent_;
|
||
if (fitContent_)
|
||
appearance.contentPadRight = appearance.contentPadLeft + appearance.textPadLeft;
|
||
appearance.selectedBarWidth = tokens.selectedBarWidth;
|
||
appearance.selectedBarHeight = fontSize * tokens.selectedBarHeightEm;
|
||
appearance.selectedBarInside = tokens.selectedBarInside;
|
||
appearance.showSelectedBar = tokens.showSelectedBar;
|
||
appearance.selectedBarColor = tokens.selectedBar.a > 0.001f ? tokens.selectedBar : tokens.accent;
|
||
appearance.cornerRadius = tokens.itemRadius;
|
||
// 预编辑行隐藏时列表顶边才贴着卡片顶边,与 CSS 的 .preedit-hidden 同一判据。
|
||
const bool preeditHidden = GetConfiguredCandidateWindowPreeditStyle() == "empty";
|
||
appearance.outerCornerRadius = tokens.outerItemRadius;
|
||
appearance.outerTopCornersEnabled = preeditHidden;
|
||
appearance.justifyHorizontalRows = tokens.justifyRows;
|
||
// 隐藏的预编辑行高度为 0,但 body 仍在它和列表之间留 2px 行距;贴角皮肤把它抵掉,
|
||
// 让首项贴着卡片的内容顶边(秋桂四边内边距一致,杨柳青高亮直抵卡片顶边)。
|
||
if (preeditHidden)
|
||
impl_->preedit->SetMargin(
|
||
{0.0f, 0.0f, 0.0f, tokens.outerItemRadius > 0.0f || tokens.trimHiddenPreeditGap ? -2.0f : 0.0f});
|
||
else
|
||
impl_->preedit->SetMargin(tokens.preeditMargin);
|
||
appearance.textColor = palette.candidateText;
|
||
appearance.labelColor = tokens.number;
|
||
appearance.annotationColor = palette.candidateText;
|
||
appearance.translationColor = tokens.translation;
|
||
appearance.rowTranslationSelected = tokens.rowTranslationSelected;
|
||
appearance.translationSecondaryColor = tokens.translationPos;
|
||
appearance.translationDelimiterColor = tokens.translationSeparator;
|
||
appearance.rowTranslationSecondarySelected = tokens.rowTranslationPosSelected;
|
||
appearance.rowTranslationDelimiterSelected = tokens.rowTranslationSeparatorSelected;
|
||
appearance.rowFillSelected = tokens.selected;
|
||
appearance.rowFillHover = tokens.hover;
|
||
appearance.rowFillPressed = tokens.selected;
|
||
appearance.rowTextSelected = tokens.rowTextSelected;
|
||
appearance.rowLabelSelected = tokens.rowLabelSelected;
|
||
impl_->menuFill = tokens.menuFill;
|
||
impl_->menuBorder = tokens.menuBorder;
|
||
impl_->menuText = tokens.menuText;
|
||
impl_->menuHover = tokens.menuHover;
|
||
impl_->list->SetAppearance(appearance);
|
||
impl_->list->SetOrientation(GetConfiguredCandidateWindowLayout() == "horizontal"
|
||
? msimeui::CandidateList::Orientation::Horizontal
|
||
: msimeui::CandidateList::Orientation::Vertical);
|
||
if (impl_->pager)
|
||
{
|
||
// 箭头跟着候选字号缩放,与序号差不多大;颜色用序号色,悬停底色沿用候选项的悬停色。
|
||
msimeui::PagerArrows::Appearance pagerAppearance;
|
||
pagerAppearance.buttonWidth = std::round(fontSize * 0.72f);
|
||
pagerAppearance.buttonHeight = std::round(fontSize * 0.9f);
|
||
pagerAppearance.gap = 1.0f;
|
||
pagerAppearance.glyphSize = fontSize * 0.5f;
|
||
pagerAppearance.strokeWidth = (std::max)(1.2f, fontSize / 13.0f);
|
||
pagerAppearance.cornerRadius = (std::min)(3.0f, tokens.itemRadius);
|
||
pagerAppearance.glyphColor = tokens.number;
|
||
pagerAppearance.disabledGlyphColor = WithAlpha(tokens.number, 0.3f);
|
||
pagerAppearance.hoverFill = tokens.hover;
|
||
pagerAppearance.pressedFill = tokens.selected.a > 0.001f ? tokens.selected : tokens.hover;
|
||
if (tokens.trianglePager)
|
||
{
|
||
// 微软拼音式:候选文字色的实心三角,按钮与候选行等高。横排时按钮更宽,前面隔一条分隔线,
|
||
// 与 CSS 的 .page-arrow 同一组 em 尺寸。
|
||
pagerAppearance.glyph = msimeui::PagerArrows::Glyph::Triangle;
|
||
// 高 0.56em、宽 0.47em,三个角各圆掉高度的四分之一,尖端是钝的。
|
||
pagerAppearance.glyphSize = fontSize * 0.56f;
|
||
pagerAppearance.strokeWidth = pagerAppearance.glyphSize * 0.25f;
|
||
pagerAppearance.cornerRadius = tokens.itemRadius;
|
||
pagerAppearance.gap = 0.0f;
|
||
pagerAppearance.glyphColor = palette.candidateText;
|
||
pagerAppearance.disabledGlyphColor = WithAlpha(palette.candidateText, 0.25f);
|
||
pagerAppearance.buttonHeight = std::round(appearance.itemHeight);
|
||
if (horizontalLayout)
|
||
{
|
||
pagerAppearance.buttonWidth = std::round(fontSize * 1.73f);
|
||
pagerAppearance.dividerWidth = 1.0f;
|
||
pagerAppearance.dividerGap = std::round(fontSize * 0.12f);
|
||
pagerAppearance.dividerColor = tokens.pagerDivider;
|
||
}
|
||
else
|
||
{
|
||
pagerAppearance.buttonWidth = std::round(fontSize * 1.2f);
|
||
pagerAppearance.buttonHeight = std::round(fontSize * 1.2f);
|
||
}
|
||
}
|
||
impl_->pager->SetAppearance(pagerAppearance);
|
||
if (horizontalLayout && tokens.trianglePager)
|
||
{
|
||
// 与最后一项隔 0.3em,按钮与行等高,不用再抬。
|
||
impl_->pager->SetMargin({std::round(fontSize * 0.3f), 0.0f, 0.0f, 0.0f});
|
||
}
|
||
else if (horizontalLayout)
|
||
{
|
||
// 横排:窄列与候选之间留一点空,箭头竖直居中在最后一行上。
|
||
const float lift = (std::max)(0.0f, (appearance.itemHeight - pagerAppearance.buttonHeight) * 0.5f);
|
||
impl_->pager->SetMargin({2.0f, 0.0f, 1.0f, lift});
|
||
}
|
||
else
|
||
{
|
||
// 竖排:矮行抵掉 body 的 2px 行距,贴着最后一个候选、靠左,“‹”的左端与序号左端对齐。
|
||
// 序号从候选行的 contentPadLeft + textPadLeft 处开始;箭头在按钮里居中,要减去它左侧的留白。
|
||
impl_->pager->SetHorizontalAlignment(msimeui::HorizontalAlignment::Leading);
|
||
const float labelLeft = appearance.contentPadLeft + appearance.textPadLeft;
|
||
const float glyphInset = (pagerAppearance.buttonWidth - pagerAppearance.GlyphWidth()) * 0.5f;
|
||
impl_->pager->SetMargin({labelLeft - glyphInset, -1.0f, 0.0f, 1.0f});
|
||
}
|
||
}
|
||
impl_->preedit->SetFontFamily(appearance.fontFamily);
|
||
impl_->preedit->SetFallbackFontFamilies(appearance.fallbackFontFamilies);
|
||
impl_->preedit->SetFontSize(preeditSize);
|
||
impl_->preedit->SetColor(palette.preeditText);
|
||
impl_->preedit->SetCaretColor(tokens.preeditCaret.a > 0.001f ? tokens.preeditCaret : tokens.accent);
|
||
impl_->preedit->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_LEADING);
|
||
impl_->preedit->SetTextLayoutPadding({5.0f, 0.0f, 5.0f, 0.0f});
|
||
// 预编辑背景与分隔线对应 CSS 的 .pinyin background / border-bottom:底色带与高亮同圆角,分隔线固定 1px;
|
||
// 有其一时预编辑行横向铺满,否则底色只包住文字。
|
||
impl_->preedit->SetBackground(tokens.preeditBackground, tokens.itemRadius);
|
||
impl_->preedit->SetBottomRule(tokens.preeditDivider, 1.0f);
|
||
impl_->preedit->SetHorizontalAlignment(tokens.preeditBackground.a > 0.001f || tokens.preeditDivider.a > 0.001f
|
||
? msimeui::HorizontalAlignment::Stretch
|
||
: msimeui::HorizontalAlignment::Leading);
|
||
|
||
// 装饰图与 WebView2 端的 .containerParent::before 同一几何:宽 width_dip、高 top_inset_dip 的盒子
|
||
// 底边贴着卡片顶边、不与卡片重叠,按 align 贴卡片左/中/右,图片在盒内等比居中(contain)。
|
||
decorationTopDip_ = 0.0f;
|
||
decorationWidthDip_ = 0.0f;
|
||
cardMinWidthDip_ = fitContent_ ? 0.0f : kCandidateMinWidthDip;
|
||
std::wstring decorationPath;
|
||
std::wstring backgroundPath;
|
||
const std::wstring packageRoot = package ? skinsRoot + L"\\" + string_to_wstring(package->id) + L"\\" : L"";
|
||
if (package)
|
||
{
|
||
decorationTopDip_ = static_cast<float>(package->decorationTopDip);
|
||
decorationWidthDip_ = static_cast<float>(package->decorationWidthDip);
|
||
if (!package->decorationImage.empty())
|
||
decorationPath = packageRoot + string_to_wstring(package->decorationImage);
|
||
if (!package->backgroundImage.empty())
|
||
backgroundPath = packageRoot + string_to_wstring(package->backgroundImage);
|
||
cardMinWidthDip_ =
|
||
(std::max)({cardMinWidthDip_, static_cast<float>(package->minWidthDip), decorationWidthDip_});
|
||
}
|
||
// 卡片上方留给阴影的 kShadowPadTop 与装饰盒重叠,装饰盒高过它时不再额外留白。
|
||
float framePadTop = kShadowPadTop;
|
||
if (decorationPath.empty() || decorationTopDip_ <= 0.0f)
|
||
{
|
||
impl_->decoration->SetHeight(0.0f);
|
||
impl_->decoration->ClearWidth();
|
||
impl_->decoration->SetSource(L"");
|
||
}
|
||
else
|
||
{
|
||
impl_->decoration->SetSource(decorationPath);
|
||
impl_->decoration->SetHeight(decorationTopDip_);
|
||
impl_->decoration->SetWidth(decorationWidthDip_);
|
||
impl_->decoration->SetHorizontalAlignment(DecorationAlignment(package->decorationAlign));
|
||
framePadTop = (std::max)(0.0f, kShadowPadTop - decorationTopDip_);
|
||
}
|
||
// v2 挂件挂在卡片外沿,窗口要按 outer_insets_dip 往四边多留出空白,否则立绘被窗口边界裁掉。
|
||
float framePadLeft = kShadowPadLeft;
|
||
float framePadRight = kShadowPadRight;
|
||
float framePadBottom = kShadowPadBottom;
|
||
if (package && package->outerInsetsDip)
|
||
{
|
||
const CandidateSkinCatalog::InsetsDip &outer = *package->outerInsetsDip;
|
||
framePadLeft += static_cast<float>(outer.left);
|
||
framePadRight += static_cast<float>(outer.right);
|
||
framePadTop += static_cast<float>(outer.top);
|
||
framePadBottom += static_cast<float>(outer.bottom);
|
||
}
|
||
if (impl_->frame)
|
||
{
|
||
impl_->frame->SetPadding({framePadLeft, framePadTop, framePadRight, framePadBottom});
|
||
}
|
||
|
||
// v2 自由挂件交给 overlay 层绘制;它测量为 0,不参与卡片布局。
|
||
if (impl_->skinOverlay)
|
||
{
|
||
std::vector<CandidateSkinOverlay::Item> items;
|
||
if (package)
|
||
{
|
||
for (const CandidateSkinCatalog::FreeDecoration &dec : package->freeDecorations)
|
||
{
|
||
CandidateSkinOverlay::Item item;
|
||
item.path = packageRoot + string_to_wstring(dec.image);
|
||
item.anchor = dec.anchor;
|
||
item.offsetXDip = static_cast<float>(dec.offsetXDip);
|
||
item.offsetYDip = static_cast<float>(dec.offsetYDip);
|
||
item.widthDip = static_cast<float>(dec.widthDip);
|
||
item.heightDip = static_cast<float>(dec.heightDip);
|
||
items.push_back(std::move(item));
|
||
}
|
||
}
|
||
impl_->skinOverlay->SetItems(std::move(items));
|
||
}
|
||
// v2 的 line_gap_dip 落到预编辑行与候选列表之间的间距上。
|
||
// TODO(v2 皮肤): 暂定 WebView2 独占。SetSpacing 作用于 body 的全部子元素,竖排时列表与翻页栏之间也会被拉开,
|
||
// WebView2 只加在 .row.pinyin 下方。
|
||
if (impl_->body)
|
||
{
|
||
impl_->body->SetSpacing(package && package->lineGapDip ? static_cast<float>(*package->lineGapDip) : 2.0f);
|
||
}
|
||
|
||
msimeui::Brush brush;
|
||
brush.fill = tokens.surface;
|
||
brush.stroke = tokens.border;
|
||
brush.strokeWidth = tokens.borderWidth;
|
||
brush.radiusX = tokens.radius;
|
||
brush.radiusY = tokens.radius;
|
||
if (impl_->card)
|
||
{
|
||
impl_->card->SetBrush(brush);
|
||
if (package && package->contentInsetsDip)
|
||
{
|
||
// v2 的 content_insets_dip 从面板外缘量起,D2D 侧直接落成卡片内边距(这里没有 CSS 的边框内缘概念)。
|
||
const CandidateSkinCatalog::InsetsDip &insets = *package->contentInsetsDip;
|
||
impl_->card->SetPadding(msimeui::Thickness{static_cast<float>(insets.left), static_cast<float>(insets.top),
|
||
static_cast<float>(insets.right),
|
||
static_cast<float>(insets.bottom)});
|
||
}
|
||
else
|
||
{
|
||
impl_->card->SetPadding(tokens.containerPad);
|
||
}
|
||
impl_->card->SetMinWidth(cardMinWidthDip_);
|
||
if (package && package->borderImage && !package->borderImage->source.empty())
|
||
{
|
||
// v2 九宫格:四边原样、其余拉伸。切片按 1/2/4 值展开成「上右下左」。
|
||
// TODO(v2 皮肤): 暂定 WebView2 独占。D2D 尚未处理 width_dip 与 fill = false,九宫格仍叠在卡片底色、
|
||
// 描边与阴影之上并被卡片圆角裁剪;WebView2 会把这些清成透明、圆角归零。
|
||
const CandidateSkinCatalog::BorderImage &bi = *package->borderImage;
|
||
const std::vector<double> &values = bi.stretchDip.empty() ? bi.sliceDip : bi.stretchDip;
|
||
const auto expand4 = [](const std::vector<double> &v, float out[4]) {
|
||
if (v.size() == 1)
|
||
out[0] = out[1] = out[2] = out[3] = static_cast<float>(v[0]);
|
||
else if (v.size() == 2)
|
||
{
|
||
out[0] = out[2] = static_cast<float>(v[0]);
|
||
out[1] = out[3] = static_cast<float>(v[1]);
|
||
}
|
||
else
|
||
{
|
||
for (size_t i = 0; i < 4; ++i)
|
||
out[i] = i < v.size() ? static_cast<float>(v[i]) : 0.0f;
|
||
}
|
||
};
|
||
float slice[4] = {0.0f, 0.0f, 0.0f, 0.0f};
|
||
expand4(values, slice);
|
||
impl_->card->SetBackgroundImageNineSlice(packageRoot + string_to_wstring(bi.source), slice[3], slice[0],
|
||
slice[1], slice[2], 1.0f);
|
||
}
|
||
else
|
||
{
|
||
impl_->card->SetBackgroundImage(backgroundPath, BackgroundStretch(package ? package->backgroundFit : ""),
|
||
package ? static_cast<float>(package->backgroundOpacity) : 1.0f);
|
||
}
|
||
}
|
||
}
|
||
|
||
std::uint64_t CandidatePresenter::FillItemsFromUi()
|
||
{
|
||
const Global::CandidatePageSnapshotPtr page = Global::LoadCandidatePageSnapshot();
|
||
const bool translationsEnabled = GetConfiguredCandidateTranslationsEnabled();
|
||
std::vector<msimeui::CandidateList::Item> items;
|
||
items.reserve(page->page_views.size());
|
||
for (size_t i = 0; i < page->page_views.size(); ++i)
|
||
{
|
||
const auto &view = page->page_views[i];
|
||
msimeui::CandidateList::Item item;
|
||
item.label = std::to_wstring(i + 1);
|
||
item.text = string_to_wstring(view.text + view.badge);
|
||
item.annotation = string_to_wstring(view.annotation);
|
||
if (translationsEnabled)
|
||
{
|
||
// 逐段转成 UTF-16 再拼起来,区间按拼出来的串计,与 WebView2 的 .cand-pos / .cand-sep 同一切法。
|
||
for (const auto &segment : FanyImeIpc::SegmentTranslationGloss(view.translation))
|
||
{
|
||
const std::wstring text = string_to_wstring(segment.text);
|
||
if (segment.kind != FanyImeIpc::TranslationSegmentKind::Text)
|
||
{
|
||
item.translationRuns.push_back({static_cast<UINT32>(item.translation.size()),
|
||
static_cast<UINT32>(text.size()),
|
||
segment.kind == FanyImeIpc::TranslationSegmentKind::Separator
|
||
? msimeui::CandidateList::TextRunRole::Delimiter
|
||
: msimeui::CandidateList::TextRunRole::Secondary});
|
||
}
|
||
item.translation += text;
|
||
}
|
||
}
|
||
items.push_back(std::move(item));
|
||
}
|
||
ignoreSelectionCallback_ = true;
|
||
impl_->list->SetItems(std::move(items));
|
||
impl_->list->SetSelectedIndex(static_cast<size_t>((std::max)(0, page->selected_index_in_page)));
|
||
ignoreSelectionCallback_ = false;
|
||
if (impl_->pager)
|
||
{
|
||
impl_->pager->SetEnabled(page->has_previous_page, page->has_next_page);
|
||
}
|
||
return page->generation;
|
||
}
|
||
|
||
void CandidatePresenter::CommitItem(size_t pageIndex)
|
||
{
|
||
if (!hwnd_)
|
||
{
|
||
return;
|
||
}
|
||
PostMessageW(hwnd_, WM_COMMIT_CANDIDATE, static_cast<WPARAM>(pageIndex + 1), 0);
|
||
}
|
||
|
||
namespace
|
||
{
|
||
constexpr float kContextMenuWidthDip = 108.0f;
|
||
constexpr float kContextSubmenuWidthDip = 96.0f;
|
||
|
||
void StyleContextPopup(msimeui::Popup &popup, const D2D1_COLOR_F &fill, const D2D1_COLOR_F &border, float width)
|
||
{
|
||
popup.SetMatchAnchorWidth(false);
|
||
popup.SetWidth(width);
|
||
popup.SetPadding({2.0f, 2.0f, 2.0f, 2.0f});
|
||
popup.SetBackgroundFill(fill);
|
||
popup.SetBorderColor(border);
|
||
popup.SetCornerRadius(6.0f);
|
||
popup.SetShadowEnabled(true);
|
||
popup.SetOffset(0.0f, 0.0f);
|
||
popup.SetConstrainToViewport(true);
|
||
}
|
||
} // namespace
|
||
|
||
void CandidatePresenter::ShowItemContextMenu(size_t pageIndex, POINT clientPoint)
|
||
{
|
||
if (!hwnd_ || !impl_ || !impl_->list || !impl_->window)
|
||
{
|
||
return;
|
||
}
|
||
|
||
CloseContextMenu(true);
|
||
impl_->contextMenuPageIndex = pageIndex;
|
||
|
||
// A mouse callback has no posted-message handoff at all, so it must not walk the worker's live page either.
|
||
const Global::CandidatePageSnapshotPtr page = Global::LoadCandidatePageSnapshot();
|
||
std::wstring word;
|
||
if (pageIndex < page->page_words.size())
|
||
{
|
||
word = page->page_words[pageIndex];
|
||
}
|
||
size_t codePoints = 0;
|
||
for (size_t i = 0; i < word.size(); ++i)
|
||
{
|
||
++codePoints;
|
||
if (i + 1 < word.size() && IS_HIGH_SURROGATE(word[i]) && IS_LOW_SURROGATE(word[i + 1]))
|
||
{
|
||
++i;
|
||
}
|
||
}
|
||
const bool showDelete = codePoints != 1;
|
||
const WPARAM oneBased = static_cast<WPARAM>(pageIndex + 1);
|
||
|
||
auto makeItem = [this](const std::wstring &text, bool hasSubmenu, std::function<void()> onClick) {
|
||
auto item = std::make_shared<msimeui::MenuFlyoutItem>(text, hasSubmenu);
|
||
item->SetColors(impl_->menuText, impl_->menuHover);
|
||
if (onClick)
|
||
{
|
||
item->SetOnClick(std::move(onClick));
|
||
}
|
||
return item;
|
||
};
|
||
|
||
auto stack = std::make_shared<msimeui::StackPanel>(0.0f);
|
||
auto pin = makeItem(L"置顶", false, [this, oneBased]() {
|
||
CloseContextMenu(true);
|
||
PostMessageW(hwnd_, WM_PIN_TO_TOP_CANDIDATE, oneBased, 0);
|
||
});
|
||
pin->SetOnHover([this](bool hovered) {
|
||
if (hovered)
|
||
{
|
||
CloseFixSubmenu();
|
||
}
|
||
});
|
||
impl_->fixPositionItem = makeItem(L"固定排位", true, {});
|
||
impl_->fixPositionItem->SetOnHover([this](bool hovered) {
|
||
if (hovered)
|
||
{
|
||
OpenFixSubmenu();
|
||
}
|
||
});
|
||
stack->AddChild(pin);
|
||
stack->AddChild(impl_->fixPositionItem);
|
||
if (showDelete)
|
||
{
|
||
auto del = makeItem(L"删除", false, [this, oneBased]() {
|
||
CloseContextMenu(true);
|
||
PostMessageW(hwnd_, WM_DELETE_CANDIDATE, oneBased, 0);
|
||
});
|
||
del->SetOnHover([this](bool hovered) {
|
||
if (hovered)
|
||
{
|
||
CloseFixSubmenu();
|
||
}
|
||
});
|
||
stack->AddChild(del);
|
||
}
|
||
|
||
auto subStack = std::make_shared<msimeui::StackPanel>(0.0f);
|
||
for (int position = 1; position <= 5; ++position)
|
||
{
|
||
const std::wstring label = L"第 " + std::to_wstring(position) + L" 位";
|
||
subStack->AddChild(makeItem(label, false, [this, oneBased, position]() {
|
||
CloseContextMenu(true);
|
||
PostMessageW(hwnd_, WM_FIX_CANDIDATE_POSITION, oneBased, position);
|
||
}));
|
||
}
|
||
auto separator = std::make_shared<msimeui::MenuSeparator>();
|
||
separator->SetColor(D2D1::ColorF(impl_->menuText.r, impl_->menuText.g, impl_->menuText.b, 0.125f));
|
||
subStack->AddChild(separator);
|
||
subStack->AddChild(makeItem(L"取消固定", false, [this, oneBased]() {
|
||
CloseContextMenu(true);
|
||
PostMessageW(hwnd_, WM_CLEAR_CANDIDATE_POSITION, oneBased, 0);
|
||
}));
|
||
|
||
impl_->contextMenu = std::make_shared<msimeui::Popup>(stack);
|
||
StyleContextPopup(*impl_->contextMenu, impl_->menuFill, impl_->menuBorder, kContextMenuWidthDip);
|
||
impl_->contextSubmenu = std::make_shared<msimeui::Popup>(subStack);
|
||
StyleContextPopup(*impl_->contextSubmenu, impl_->menuFill, impl_->menuBorder, kContextSubmenuWidthDip);
|
||
|
||
const msimeui::PointF anchor = impl_->window->ClientPixelsToDips(clientPoint);
|
||
impl_->contextMenu->SetAnchorRect({anchor.x, anchor.y, 1.0f, 1.0f});
|
||
ExpandHostForMenu(clientPoint);
|
||
|
||
if (msimeui::Scene *scene = impl_->window->GetScene())
|
||
{
|
||
scene->AddPopup(impl_->contextMenu, [this]() { CloseContextMenu(true); });
|
||
impl_->contextMenuOpen = true;
|
||
}
|
||
Present();
|
||
}
|
||
|
||
void CandidatePresenter::CloseContextMenu(bool restoreHost)
|
||
{
|
||
if (!impl_ || !impl_->window)
|
||
{
|
||
return;
|
||
}
|
||
|
||
// ShowFromGlobalState and Hide call this on every keystroke, so the hover
|
||
// teardown and the repaint below only run when a menu is actually up.
|
||
const bool hadPopup = impl_->contextMenu || impl_->contextSubmenu;
|
||
if (hadPopup)
|
||
{
|
||
// Window caches the hovered and focused Visual as raw pointers. They
|
||
// point into the popup destroyed a few lines below, so they have to be
|
||
// dropped first or the next mouse message dispatches through a dangling
|
||
// pointer.
|
||
impl_->window->DispatchImportedMessage(WM_MOUSELEAVE, 0, 0);
|
||
impl_->window->FocusVisual(nullptr);
|
||
}
|
||
|
||
if (msimeui::Scene *scene = impl_->window->GetScene())
|
||
{
|
||
if (impl_->contextSubmenu)
|
||
{
|
||
scene->RemovePopup(impl_->contextSubmenu.get(), false);
|
||
}
|
||
if (impl_->contextMenu)
|
||
{
|
||
scene->RemovePopup(impl_->contextMenu.get(), false);
|
||
}
|
||
}
|
||
impl_->contextMenuOpen = false;
|
||
impl_->contextSubmenuOpen = false;
|
||
impl_->fixPositionItem.reset();
|
||
impl_->contextMenu.reset();
|
||
impl_->contextSubmenu.reset();
|
||
if (restoreHost)
|
||
{
|
||
RestoreHostAfterMenu();
|
||
}
|
||
else
|
||
{
|
||
impl_->hostExpandedForMenu = false;
|
||
}
|
||
if (hadPopup)
|
||
{
|
||
Present();
|
||
}
|
||
}
|
||
|
||
void CandidatePresenter::OpenFixSubmenu()
|
||
{
|
||
if (!impl_ || !impl_->window || !impl_->contextSubmenu || !impl_->fixPositionItem || impl_->contextSubmenuOpen)
|
||
{
|
||
return;
|
||
}
|
||
const msimeui::RectF anchor = impl_->fixPositionItem->GetBounds();
|
||
if (anchor.width < 1.0f || anchor.height < 1.0f)
|
||
{
|
||
return;
|
||
}
|
||
impl_->contextSubmenu->SetAnchorRect(anchor);
|
||
impl_->contextSubmenu->SetOffset(anchor.width - 2.0f, -anchor.height);
|
||
if (msimeui::Scene *scene = impl_->window->GetScene())
|
||
{
|
||
scene->AddPopup(impl_->contextSubmenu, [this]() { impl_->contextSubmenuOpen = false; });
|
||
impl_->contextSubmenuOpen = true;
|
||
}
|
||
}
|
||
|
||
void CandidatePresenter::CloseFixSubmenu()
|
||
{
|
||
if (!impl_ || !impl_->window || !impl_->contextSubmenu || !impl_->contextSubmenuOpen)
|
||
{
|
||
return;
|
||
}
|
||
// The submenu visual outlives this call (contextSubmenu keeps it alive for
|
||
// a later reopen), but it leaves the scene here, so the cached hover has to
|
||
// go with it. Focus stays where it is: the parent menu is still open.
|
||
impl_->window->DispatchImportedMessage(WM_MOUSELEAVE, 0, 0);
|
||
if (msimeui::Scene *scene = impl_->window->GetScene())
|
||
{
|
||
scene->RemovePopup(impl_->contextSubmenu.get(), false);
|
||
}
|
||
impl_->contextSubmenuOpen = false;
|
||
Present();
|
||
}
|
||
|
||
void CandidatePresenter::ExpandHostForMenu(POINT clientPoint)
|
||
{
|
||
if (!hwnd_ || !impl_ || !impl_->window)
|
||
{
|
||
return;
|
||
}
|
||
RECT windowRect{};
|
||
RECT clientRect{};
|
||
GetWindowRect(hwnd_, &windowRect);
|
||
GetClientRect(hwnd_, &clientRect);
|
||
if (!impl_->hostExpandedForMenu)
|
||
{
|
||
impl_->hostRectBeforeMenu = windowRect;
|
||
impl_->hostExpandedForMenu = true;
|
||
}
|
||
|
||
const float dpi = impl_->window->GetDpi();
|
||
const msimeui::PointF anchor = impl_->window->ClientPixelsToDips(clientPoint);
|
||
const msimeui::SizeF clientDip = impl_->window->ClientPixelsToDips(SIZE{clientRect.right, clientRect.bottom});
|
||
const float needWidth =
|
||
(std::max)(clientDip.width, anchor.x + kContextMenuWidthDip + kContextSubmenuWidthDip + 16.0f);
|
||
const float needHeight = (std::max)(clientDip.height, (std::max)(anchor.y + 168.0f, 176.0f));
|
||
const int widthPx = (std::max)(static_cast<int>(windowRect.right - windowRect.left),
|
||
static_cast<int>(std::ceil(needWidth * dpi / 96.0f)));
|
||
const int heightPx = (std::max)(static_cast<int>(windowRect.bottom - windowRect.top),
|
||
static_cast<int>(std::ceil(needHeight * dpi / 96.0f)));
|
||
if (widthPx == windowRect.right - windowRect.left && heightPx == windowRect.bottom - windowRect.top)
|
||
{
|
||
return;
|
||
}
|
||
SetWindowPos(hwnd_, HWND_TOPMOST, windowRect.left, windowRect.top, widthPx, heightPx,
|
||
SWP_NOACTIVATE | SWP_NOZORDER);
|
||
}
|
||
|
||
void CandidatePresenter::RestoreHostAfterMenu()
|
||
{
|
||
if (!hwnd_ || !impl_ || !impl_->hostExpandedForMenu)
|
||
{
|
||
return;
|
||
}
|
||
const RECT &rc = impl_->hostRectBeforeMenu;
|
||
SetWindowPos(hwnd_, nullptr, rc.left, rc.top, rc.right - rc.left, rc.bottom - rc.top,
|
||
SWP_NOACTIVATE | SWP_NOZORDER);
|
||
impl_->hostExpandedForMenu = false;
|
||
}
|
||
|
||
void CandidatePresenter::PlaceAndShow(POINT caret, float widthDip, float heightDip, float cardLeftDip, float cardTopDip,
|
||
const ResolvedCandidateScale &resolved)
|
||
{
|
||
FLOAT scale = resolved.scale;
|
||
CandidateScaleSource scaleSource = resolved.source;
|
||
if (scale <= 0.0f)
|
||
{
|
||
const ResolvedCandidateScale now = ResolveCandidateScaleForCaret(caret);
|
||
scale = now.scale;
|
||
scaleSource = now.source;
|
||
}
|
||
if (scale <= 0.0f)
|
||
{
|
||
scale = 1.0f;
|
||
scaleSource = CandidateScaleSource::Monitor;
|
||
}
|
||
const HalfScreenDipLimits limits = ApplyScaleToHalfScreenLimits(caret, scale);
|
||
widthDip = static_cast<float>(ClampWidthDipToHalfScreen(widthDip, limits));
|
||
heightDip = static_cast<float>(ClampHeightDipToHalfScreen(heightDip, limits));
|
||
lastLayoutWidthDip_ = widthDip;
|
||
lastLayoutHeightDip_ = heightDip;
|
||
cardLeftDip = (std::max)(0.0f, cardLeftDip);
|
||
cardTopDip = (std::max)(0.0f, (std::min)(cardTopDip, heightDip));
|
||
float cardWidthDip = (std::max)(widthDip - cardLeftDip - kShadowPadRight, 1.0f);
|
||
float cardHeightDip = (std::max)(heightDip - cardTopDip - kShadowPadBottom, 1.0f);
|
||
if (impl_->card)
|
||
{
|
||
const msimeui::RectF card = impl_->card->GetBounds();
|
||
if (card.width > 1.0f)
|
||
{
|
||
cardWidthDip = card.width;
|
||
}
|
||
if (card.height > 1.0f)
|
||
{
|
||
cardHeightDip = card.height;
|
||
}
|
||
}
|
||
auto properPos = std::make_shared<std::pair<int, int>>();
|
||
// Position the opaque card, not the decoration or drop shadow around it.
|
||
AdjustCandidateWindowPosition(&caret, {cardWidthDip, cardHeightDip}, properPos, scale, cardWidthDip);
|
||
int widthPx = AlignHostPixels((std::max)(1, static_cast<int>(std::ceil(widthDip * scale))));
|
||
int heightPx = AlignHostPixels((std::max)(1, static_cast<int>(std::ceil(heightDip * scale))));
|
||
widthPx = (std::max)(widthPx, lastHostWidthPx_);
|
||
heightPx = (std::max)(heightPx, lastHostHeightPx_);
|
||
lastHostWidthPx_ = widthPx;
|
||
lastHostHeightPx_ = heightPx;
|
||
const int cardLeftPx = static_cast<int>(std::lround(cardLeftDip * scale));
|
||
const int cardTopPx = static_cast<int>(std::lround(cardTopDip * scale));
|
||
const int cardHeightPx = static_cast<int>(std::lround(cardHeightDip * scale));
|
||
int x = properPos->first - cardLeftPx;
|
||
int y = properPos->second - cardTopPx;
|
||
const MonitorCoordinates monitor = GetMonitorCoordinatesFromPoint(caret);
|
||
// Clamp the opaque card, not the host window — same rule the vertical branch
|
||
// below already follows. The host carries transparent shadow padding, is
|
||
// rounded up to a 64px bucket and only ever grows while typing, so clamping
|
||
// *its* right edge shoved the card left by all of that slack. AdjustCandidate-
|
||
// WindowPosition had already parked the card flush against the screen edge;
|
||
// deleting a character kept the stale (larger) host width, so the card's left
|
||
// edge froze and its right edge drifted further from the edge on every delete.
|
||
const int cardWidthPx = (std::max)(1, static_cast<int>(std::ceil(cardWidthDip * scale)));
|
||
if (x + cardLeftPx + cardWidthPx > monitor.right)
|
||
{
|
||
x = monitor.right - cardLeftPx - cardWidthPx - 2;
|
||
}
|
||
if (x + cardLeftPx < monitor.left)
|
||
{
|
||
x = monitor.left + 2 - cardLeftPx;
|
||
}
|
||
const int cardBottom = y + cardTopPx + cardHeightPx;
|
||
if (cardBottom > monitor.bottom)
|
||
{
|
||
y = monitor.bottom - cardTopPx - cardHeightPx - 2;
|
||
}
|
||
// Allow the mascot to clip above the work area so the card stays on the caret.
|
||
if (y + cardTopPx < monitor.top)
|
||
{
|
||
y = monitor.top + 2 - cardTopPx;
|
||
}
|
||
// Rendering must use the same scale the window is sized with: the ui
|
||
// override replaces GetDpiForWindow (144 inside RDP) so DIPs map exactly
|
||
// onto the pixels computed above. Must land before EnsureForComposition —
|
||
// a freshly created target reads it through DpiForHwnd() — and before the
|
||
// resize below, whose WM_SIZE already presents.
|
||
const FLOAT dpiOverride = scale * 96.0f;
|
||
impl_->window->SetDpiOverride(dpiOverride);
|
||
impl_->resources.SetDpiOverride(dpiOverride);
|
||
// The card is clamped to the work area, but the host around it carries the
|
||
// shadow padding and the 64px bucket slack, so near the right or bottom edge
|
||
// it reached onto the neighbouring monitor. DWM applies a window move and a
|
||
// swap-chain present independently and in no guaranteed order, so a frame
|
||
// can pair either card with either host origin; whatever it put past the
|
||
// card then flashed on the next screen. Cut the host at the work-area edge:
|
||
// nothing can be drawn outside the caret's monitor, whatever DWM pairs up.
|
||
// The card still fits (it was clamped above); only its shadow is trimmed.
|
||
const int hostWidthPx = (std::max)(1, (std::min)(widthPx, monitor.right - x));
|
||
const int hostHeightPx = (std::max)(1, (std::min)(heightPx, monitor.bottom - y));
|
||
// Submission order still decides which stale pairing is likelier. Moving a
|
||
// host left/up before the present, and right/down after it, keeps the
|
||
// stale frame on the side of the card that is already on screen.
|
||
RECT current{};
|
||
DWORD cloaked = 0;
|
||
const bool onScreen =
|
||
IsWindowVisible(hwnd_) && GetWindowRect(hwnd_, ¤t) && current.top != Global::INVALID_Y &&
|
||
SUCCEEDED(DwmGetWindowAttribute(hwnd_, DWMWA_CLOAKED, &cloaked, sizeof(cloaked))) && cloaked == 0;
|
||
const int firstX = onScreen && x > current.left ? current.left : x;
|
||
const int firstY = onScreen && y > current.top ? current.top : y;
|
||
// WM_SIZE from this resize would present the frame once more right before
|
||
// the explicit Present below; let it only resize the swap chain.
|
||
placingHost_ = true;
|
||
SetWindowPos(hwnd_, HWND_TOPMOST, firstX, firstY, hostWidthPx, hostHeightPx, SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
||
placingHost_ = false;
|
||
impl_->resources.EnsureForComposition(hwnd_);
|
||
Present();
|
||
// Every setter above invalidated the window, which queued a WM_PAINT that
|
||
// would render the whole card (shadow blur included) a second time. The
|
||
// frame just presented is already current.
|
||
ValidateRect(hwnd_, nullptr);
|
||
if (firstX != x || firstY != y)
|
||
{
|
||
SetWindowPos(hwnd_, nullptr, x, y, 0, 0, SWP_NOACTIVATE | SWP_NOZORDER | SWP_NOSIZE);
|
||
}
|
||
SetCandidateHostCloaked(false);
|
||
// Acceptance trace for the RDP candidate-scale fix: which scale authority
|
||
// won, and the system's own (potentially diverging) DPI values. Coordinates
|
||
// and DPI only — never user input, per the diagnostic logging red lines.
|
||
CAND_DIAG_LOGF(
|
||
L"candidate-d2d place source={} scale={:.3f} hwnd_dpi={} system_dpi={} remote={} caret=({},{}) "
|
||
L"size_px=({},{}) host=({},{}) first=({},{}) on_screen={} card_px=({},{} {}w) monitor=({},{})-({},{})",
|
||
scaleSource == CandidateScaleSource::RdpForeground ? L"rdp-foreground" : L"monitor", scale,
|
||
GetDpiForWindow(hwnd_), GetDpiForSystem(), GetSystemMetrics(SM_REMOTESESSION) ? 1 : 0, caret.x, caret.y,
|
||
hostWidthPx, hostHeightPx, x, y, firstX, firstY, onScreen, x + cardLeftPx, y + cardTopPx, cardWidthPx,
|
||
monitor.left, monitor.top, monitor.right, monitor.bottom);
|
||
}
|
||
|
||
void CandidatePresenter::ShowFromGlobalState()
|
||
{
|
||
ShowFromGlobalState(POINT{Global::Point[0], Global::Point[1]});
|
||
}
|
||
|
||
void CandidatePresenter::ShowFromGlobalState(POINT caret)
|
||
{
|
||
if (!bound_ || !hwnd_ || !impl_ || !impl_->root)
|
||
{
|
||
return;
|
||
}
|
||
// A composition can start before the host reports a usable text extent
|
||
// (first focus, or resuming after a long idle). The caret then arrives as
|
||
// (0, INVALID_Y), which placement would clamp into the monitor's work area.
|
||
// Hide even an already-visible host until a real anchor arrives. Preserve
|
||
// the show request so MoveCandidate / the settle timer can place it later.
|
||
if (caret.y == Global::INVALID_Y)
|
||
{
|
||
CloseContextMenu(false);
|
||
SetCandidateHostCloaked(true);
|
||
::is_global_wnd_cand_shown = true;
|
||
Global::SetCandidateWindowRenderedVisible(true);
|
||
CAND_DIAG_LOGF(L"candidate-d2d show deferred: no usable caret anchor ({},{})", caret.x, caret.y);
|
||
return;
|
||
}
|
||
CloseContextMenu(false);
|
||
ApplySkin();
|
||
std::wstring preedit;
|
||
size_t caretIndex = 0;
|
||
if (GetConfiguredCandidateWindowPreeditStyle() != "empty")
|
||
{
|
||
preedit = GetPreeditWithCaretMarker();
|
||
const size_t marker = preedit.find(L'\uE000');
|
||
if (marker != std::wstring::npos)
|
||
{
|
||
caretIndex = marker;
|
||
preedit.erase(marker, 1);
|
||
}
|
||
else
|
||
{
|
||
caretIndex = preedit.size();
|
||
}
|
||
}
|
||
impl_->preedit->SetText(preedit);
|
||
if (preedit.empty() && GetConfiguredCandidateWindowPreeditStyle() == "empty")
|
||
{
|
||
impl_->preedit->ClearCaret();
|
||
impl_->preedit->SetHeight(0.0f);
|
||
}
|
||
else
|
||
{
|
||
impl_->preedit->ClearHeight();
|
||
impl_->preedit->SetCaretIndex(caretIndex);
|
||
}
|
||
const std::uint64_t renderedGeneration = FillItemsFromUi();
|
||
impl_->list->SetHoverEnabled(hoverArmed_);
|
||
if (impl_->pager)
|
||
{
|
||
impl_->pager->SetHoverEnabled(hoverArmed_);
|
||
}
|
||
// Resolve the scale once per show and thread it through measure, clamping,
|
||
// window sizing and rendering DPI — a foreground window changing mid-show
|
||
// must not split measure (clamped at one scale) from placement (another).
|
||
const ResolvedCandidateScale scale = ResolveCandidateScaleForCaret(caret);
|
||
const HalfScreenDipLimits limits = ApplyScaleToHalfScreenLimits(caret, scale.scale);
|
||
const float maxW = limits.maxWidthDip > 1.0 ? static_cast<float>(limits.maxWidthDip) : 480.0f;
|
||
const float maxH = limits.maxHeightDip > 1.0 ? static_cast<float>(limits.maxHeightDip) : 640.0f;
|
||
float widthDip = 0.0f;
|
||
float heightDip = 0.0f;
|
||
auto layoutRoot = [&]() {
|
||
impl_->root->InvalidateMeasure();
|
||
const msimeui::SizeF measured = impl_->root->MeasureInLayout({maxW, maxH});
|
||
widthDip = (std::max)(measured.width, cardMinWidthDip_ + kShadowPadLeft + kShadowPadRight);
|
||
heightDip = (std::max)(measured.height, 36.0f);
|
||
impl_->root->InvalidateArrange();
|
||
impl_->root->ArrangeInLayout({0.0f, 0.0f, widthDip, heightDip});
|
||
};
|
||
// Late additions to the page (translations, cloud/AI/English merges) change
|
||
// the card's size between two frames of the same input, and the eye reads
|
||
// that as flicker even though every frame is complete. For one input
|
||
// (preedit + caret + page) the card therefore only grows; it shrinks back
|
||
// only when the natural size falls well below the kept one (a page that
|
||
// really got smaller). Another keystroke, caret move or page turn is a
|
||
// different page and starts from its natural size, as does Hide();
|
||
// carrying the old size over leaves blank rows at the bottom.
|
||
if (impl_->card)
|
||
{
|
||
impl_->card->SetMinWidth(cardMinWidthDip_);
|
||
impl_->card->ClearMinHeight();
|
||
}
|
||
layoutRoot();
|
||
if (impl_->card)
|
||
{
|
||
std::wstring stickyKey = GetPreeditWithCaretMarker();
|
||
stickyKey += L'#';
|
||
stickyKey += std::to_wstring(Global::candidate_ui.page_index);
|
||
if (stickyKey != stickyCardKey_)
|
||
{
|
||
stickyCardKey_ = std::move(stickyKey);
|
||
stickyCardWidthDip_ = 0.0f;
|
||
stickyCardHeightDip_ = 0.0f;
|
||
}
|
||
const msimeui::RectF natural = impl_->card->GetBounds();
|
||
const float maxCardWidthDip = (std::max)(cardMinWidthDip_, maxW - kShadowPadLeft - kShadowPadRight);
|
||
// fit_content 的卡片严格跟随文字宽度,只保留高度的只增不减。
|
||
stickyCardWidthDip_ = fitContent_
|
||
? natural.width
|
||
: (std::min)(StickyCardExtent(stickyCardWidthDip_, natural.width), maxCardWidthDip);
|
||
stickyCardHeightDip_ = StickyCardExtent(stickyCardHeightDip_, natural.height);
|
||
if (stickyCardWidthDip_ > natural.width + 0.5f || stickyCardHeightDip_ > natural.height + 0.5f)
|
||
{
|
||
impl_->card->SetMinWidth((std::max)(cardMinWidthDip_, stickyCardWidthDip_));
|
||
impl_->card->SetMinHeight(stickyCardHeightDip_);
|
||
layoutRoot();
|
||
}
|
||
}
|
||
hoverArmed_ = false;
|
||
if (!GetCursorPos(&hoverBaseline_))
|
||
{
|
||
hoverBaseline_ = {};
|
||
}
|
||
float cardLeftDip = 0.0f;
|
||
float cardTopDip = 0.0f;
|
||
if (impl_->card)
|
||
{
|
||
const msimeui::RectF card = impl_->card->GetBounds();
|
||
cardLeftDip = card.x;
|
||
cardTopDip = card.y;
|
||
}
|
||
PlaceAndShow(caret, widthDip, heightDip, cardLeftDip, cardTopDip, scale);
|
||
::is_global_wnd_cand_shown = true;
|
||
Global::SetCandidateWindowRenderedVisible(true);
|
||
// PlaceAndShow paints synchronously, so the page is on screen now. Echo the generation of the
|
||
// snapshot that was actually rendered (not the latest published one) so a pending digit/space
|
||
// selection can settle against the same page the user is looking at.
|
||
Global::PublishRenderedCandidatePageGeneration(renderedGeneration);
|
||
}
|
||
|
||
void CandidatePresenter::Hide()
|
||
{
|
||
if (!hwnd_)
|
||
{
|
||
return;
|
||
}
|
||
CloseContextMenu(false);
|
||
::is_global_wnd_cand_shown = false;
|
||
Global::SetCandidateWindowRenderedVisible(false);
|
||
hoverArmed_ = false;
|
||
wheelDeltaAccumulator_ = 0;
|
||
if (impl_ && impl_->list)
|
||
{
|
||
impl_->list->SetHoverEnabled(false);
|
||
}
|
||
if (impl_ && impl_->pager)
|
||
{
|
||
impl_->pager->SetHoverEnabled(false);
|
||
}
|
||
// Cloaking keeps the swap chain's last frame. The next show presents and
|
||
// then uncloaks, but DWM may compose the uncloaked host before it latches
|
||
// that present: one frame of the previous card at the new origin. After a
|
||
// wide card, a narrower one clamped to the right screen edge sits further
|
||
// right, so the stale card spilled onto the next monitor. Leave nothing
|
||
// behind to show.
|
||
PresentEmptyFrame();
|
||
SetCandidateHostCloaked(true);
|
||
lastHostWidthPx_ = 0;
|
||
lastHostHeightPx_ = 0;
|
||
stickyCardKey_.clear();
|
||
stickyCardWidthDip_ = 0.0f;
|
||
stickyCardHeightDip_ = 0.0f;
|
||
SetWindowPos(hwnd_, nullptr, 0, Global::INVALID_Y, 0, 0,
|
||
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_SHOWWINDOW);
|
||
}
|
||
|
||
void CandidatePresenter::Present()
|
||
{
|
||
if (!bound_ || !hwnd_ || !impl_ || !impl_->window)
|
||
{
|
||
return;
|
||
}
|
||
RECT rc{};
|
||
GetClientRect(hwnd_, &rc);
|
||
const UINT width = static_cast<UINT>((std::max)(1L, rc.right));
|
||
const UINT height = static_cast<UINT>((std::max)(1L, rc.bottom));
|
||
if (!impl_->resources.EnsureForComposition(hwnd_))
|
||
{
|
||
return;
|
||
}
|
||
impl_->resources.Resize(width, height);
|
||
ID2D1RenderTarget *target = impl_->resources.GetRenderTarget();
|
||
if (!target)
|
||
{
|
||
return;
|
||
}
|
||
target->BeginDraw();
|
||
target->Clear(D2D1::ColorF(0, 0.0f));
|
||
if (msimeui::Scene *scene = impl_->window->GetScene())
|
||
{
|
||
const float dpi = impl_->window->GetDpi();
|
||
float layoutW = msimeui::PixelsToDips(static_cast<float>(width), dpi);
|
||
float layoutH = msimeui::PixelsToDips(static_cast<float>(height), dpi);
|
||
// Host HWND is larger than the card (shadow padding, 64px buckets, and a
|
||
// grow-only size while typing). Stretching the scene to that HWND fills
|
||
// the rounded card with empty space. Keep layout at the measured DIP size.
|
||
if (!impl_->hostExpandedForMenu && lastLayoutWidthDip_ > 0.0f && lastLayoutHeightDip_ > 0.0f)
|
||
{
|
||
layoutW = lastLayoutWidthDip_;
|
||
layoutH = lastLayoutHeightDip_;
|
||
}
|
||
scene->EnsureLayout({layoutW, layoutH});
|
||
scene->Render(impl_->resources);
|
||
}
|
||
const HRESULT hr = target->EndDraw();
|
||
if (hr == D2DERR_RECREATE_TARGET)
|
||
{
|
||
impl_->resources.DiscardTarget();
|
||
return;
|
||
}
|
||
impl_->resources.Present();
|
||
}
|
||
|
||
void CandidatePresenter::PresentEmptyFrame()
|
||
{
|
||
if (!bound_ || !impl_)
|
||
{
|
||
return;
|
||
}
|
||
ID2D1RenderTarget *target = impl_->resources.GetRenderTarget();
|
||
if (!target)
|
||
{
|
||
return;
|
||
}
|
||
target->BeginDraw();
|
||
target->Clear(D2D1::ColorF(0, 0.0f));
|
||
if (target->EndDraw() == D2DERR_RECREATE_TARGET)
|
||
{
|
||
impl_->resources.DiscardTarget();
|
||
return;
|
||
}
|
||
impl_->resources.Present();
|
||
}
|
||
|
||
void CandidatePresenter::ArmHoverIfPointerMoved()
|
||
{
|
||
POINT now{};
|
||
if (!GetCursorPos(&now))
|
||
{
|
||
return;
|
||
}
|
||
const int dx = now.x - hoverBaseline_.x;
|
||
const int dy = now.y - hoverBaseline_.y;
|
||
if (dx * dx + dy * dy < 4)
|
||
{
|
||
return;
|
||
}
|
||
hoverArmed_ = true;
|
||
if (impl_ && impl_->list)
|
||
{
|
||
impl_->list->SetHoverEnabled(true);
|
||
}
|
||
if (impl_ && impl_->pager)
|
||
{
|
||
impl_->pager->SetHoverEnabled(true);
|
||
}
|
||
}
|
||
|
||
bool CandidatePresenter::HandleMessage(UINT message, WPARAM wParam, LPARAM lParam)
|
||
{
|
||
if (!bound_ || !impl_ || !impl_->window)
|
||
{
|
||
return false;
|
||
}
|
||
switch (message)
|
||
{
|
||
case WM_ERASEBKGND:
|
||
return true;
|
||
case WM_PAINT: {
|
||
PAINTSTRUCT ps{};
|
||
BeginPaint(hwnd_, &ps);
|
||
Present();
|
||
EndPaint(hwnd_, &ps);
|
||
return true;
|
||
}
|
||
case WM_SIZE:
|
||
if (::is_global_wnd_cand_shown)
|
||
{
|
||
impl_->resources.Resize(static_cast<UINT>(LOWORD(lParam)), static_cast<UINT>(HIWORD(lParam)));
|
||
if (!placingHost_)
|
||
{
|
||
Present();
|
||
}
|
||
}
|
||
return true;
|
||
case WM_MOUSEMOVE:
|
||
ArmHoverIfPointerMoved();
|
||
if (!hoverArmed_ && !(impl_ && impl_->contextMenuOpen))
|
||
{
|
||
return true;
|
||
}
|
||
impl_->window->DispatchImportedMessage(message, wParam, lParam);
|
||
Present();
|
||
return true;
|
||
case WM_LBUTTONDOWN:
|
||
case WM_LBUTTONUP:
|
||
case WM_RBUTTONUP:
|
||
case WM_MOUSELEAVE:
|
||
impl_->window->DispatchImportedMessage(message, wParam, lParam);
|
||
Present();
|
||
return true;
|
||
case WM_MOUSEWHEEL: {
|
||
if (!GetConfiguredPagingMouseWheelEnabled())
|
||
{
|
||
// Leave the message to DefWindowProc, exactly as before the feature
|
||
// existed. WM_PAGE_CANDIDATE checks the same setting for both hosts;
|
||
// this early-out only keeps a disabled wheel from being swallowed.
|
||
wheelDeltaAccumulator_ = 0;
|
||
return false;
|
||
}
|
||
if (!::is_global_wnd_cand_shown)
|
||
{
|
||
// Hide() only parks the host off-screen, so a wheel message posted
|
||
// just before the page went away can still arrive here.
|
||
wheelDeltaAccumulator_ = 0;
|
||
return true;
|
||
}
|
||
if (impl_->contextMenuOpen)
|
||
{
|
||
// Scrolling dismisses the open context menu and consumes the event.
|
||
wheelDeltaAccumulator_ = 0;
|
||
CloseContextMenu(true);
|
||
return true;
|
||
}
|
||
const CandidateWheel::PagingSteps steps =
|
||
CandidateWheel::ConsumeWheelDelta(wheelDeltaAccumulator_, GET_WHEEL_DELTA_WPARAM(wParam), WHEEL_DELTA);
|
||
// Route through the host WndProc like every other candidate UI action
|
||
// instead of reaching into the IPC layer from the window layer.
|
||
if (steps.page_up > 0)
|
||
{
|
||
PostMessageW(hwnd_, WM_PAGE_CANDIDATE, CANDIDATE_PAGE_PREVIOUS, steps.page_up);
|
||
}
|
||
if (steps.page_down > 0)
|
||
{
|
||
PostMessageW(hwnd_, WM_PAGE_CANDIDATE, CANDIDATE_PAGE_NEXT, steps.page_down);
|
||
}
|
||
return true;
|
||
}
|
||
default:
|
||
return false;
|
||
}
|
||
}
|
||
|
||
CandidatePresenter::~CandidatePresenter() = default;
|