Files
msime-windows/server/src/window/candidate_presenter.cpp
T
fanlusky dff42f92df perf(server): D2D 候选窗每次显示只绘制一遍
PlaceAndShow 同步 Present 之后,前面各个 setter 的 InvalidateRect 留下的
WM_PAINT 又把整张卡片(含阴影模糊)画一遍;宿主改尺寸时 WM_SIZE 也会在
显式 Present 之前同步画一遍。同步 Present 后 ValidateRect 丢弃过时的
WM_PAINT;PlaceAndShow 自己触发的 WM_SIZE 只调整交换链尺寸,由随后的
Present 出帧。
2026-09-27 15:03:17 +08:00

1379 lines
54 KiB
C++
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#include "window/candidate_presenter.h"
#include "config/ime_config.h"
#include "defines/defines.h"
#include "defines/globals.h"
#include "global/globals.h"
#include "ipc/ipc.h"
#include "log/candidate_diag_log.h"
#include "skin/candidate_skin_catalog.h"
#include "utils/common_utils.h"
#include "utils/ime_utils.h"
#include "utils/window_utils.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;
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);
};
void ApplyPackageColors(const CandidateSkinCatalog::CandidateColors &colors, CandSkinTokens &tokens)
{
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;
constexpr float kDecorationCardOverlap = 5.0f;
// 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;
}
} // 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;
std::shared_ptr<msimeui::Container> frame;
std::shared_ptr<msimeui::StackPanel> body;
std::shared_ptr<msimeui::TextBlock> preedit;
std::shared_ptr<msimeui::CandidateList> list;
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_->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_->body->AddChild(impl_->list);
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_->frame = std::make_shared<msimeui::Container>();
impl_->frame->SetPadding({kShadowPadLeft, kShadowPadTop, kShadowPadRight, kShadowPadBottom});
impl_->frame->SetChild(impl_->card);
impl_->root->AddChild(impl_->decoration);
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();
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);
}
// Built-in skin palettes mirror ui-html/webview2/candwnd/skins/<skin>/;
// the WebView2 CSS is the reference for both the light and dark values.
if (skinId == "wechat")
{
tokens.borderWidth = 1.0f;
tokens.radius = 5.0f;
tokens.containerPad = 2.0f;
tokens.accent = ColorFromRgb(0x07C160);
tokens.selected = ColorFromRgb(0x07C160);
tokens.showSelectedBar = false;
tokens.rowTextSelected = ColorFromRgb(0xFFFFFF);
tokens.rowLabelSelected = ColorFromRgb(0xFFFFFF);
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 (skinId == "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;
// 行内边距对齐 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);
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 (skinId == "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;
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 (skinId == "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;
if (candLight)
{
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
{
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);
}
}
const std::wstring skinsRoot = AssetRoot() + L"\\skins";
std::optional<CandidateSkinCatalog::Package> package;
if (!CandidateSkinCatalog::IsBuiltIn(skinId))
{
package = CandidateSkinCatalog::Load(std::filesystem::path(skinsRoot), skinId);
if (package)
{
ApplyPackageColors(candLight ? package->light : package->dark, tokens);
}
}
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)
{
if (CandidateSkinCatalog::IsBuiltIn(skinId) || package)
{
// 暂不解析自定义包内 CSS 的 box-shadow,先沿用内置皮肤的标准对,后续可加。
const std::vector<msimeui::ShadowPass> shadowPasses = {
{12.0f, candLight ? 0.18f : 0.34f, 8.0f, 10.0f},
{4.0f, 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 fontSize = static_cast<float>((std::max)(12, GetConfiguredCandidateFontSize()));
const float preeditSize = static_cast<float>((std::max)(12, GetConfiguredCandidateWindowPreeditFontSize()));
msimeui::CandidateList::Appearance appearance;
appearance.fontFamily = string_to_wstring(ResolveSystemFontFamilyForCss(GetConfiguredCandidateEnglishFont()));
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 * 0.8f;
appearance.annotationFontSize = fontSize;
appearance.contentPadLeft = tokens.itemPadLeft;
appearance.contentPadRight = tokens.itemPadRight;
appearance.textPadLeft = 5.0f;
appearance.labelGap = 1.5f;
appearance.selectedBarWidth = 3.0f;
appearance.selectedBarHeight = fontSize * 0.85f;
appearance.showSelectedBar = tokens.showSelectedBar;
appearance.selectedBarColor = tokens.accent;
appearance.cornerRadius = tokens.itemRadius;
// 预编辑行隐藏时列表顶边才贴着卡片顶边,与 CSS 的 .preedit-hidden 同一判据。
const bool preeditHidden = GetConfiguredCandidateWindowPreeditStyle() == "empty";
appearance.outerCornerRadius = tokens.outerItemRadius;
appearance.outerTopCornersEnabled = preeditHidden;
// 隐藏的预编辑行高度为 0,但 body 仍在它和列表之间留 2px 行距;贴角皮肤把它抵掉,
// 让首项贴着卡片的内容顶边(秋桂四边内边距一致,杨柳青高亮直抵卡片顶边)。
if (preeditHidden)
impl_->preedit->SetMargin({0.0f, 0.0f, 0.0f, tokens.outerItemRadius > 0.0f ? -2.0f : 0.0f});
else
impl_->preedit->SetMargin(tokens.preeditMargin);
appearance.textColor = theme.textPrimary;
appearance.labelColor = tokens.number;
appearance.annotationColor = theme.textPrimary;
appearance.translationColor = tokens.translation;
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);
impl_->preedit->SetFontFamily(appearance.fontFamily);
impl_->preedit->SetFallbackFontFamilies(appearance.fallbackFontFamilies);
impl_->preedit->SetFontSize(preeditSize);
impl_->preedit->SetColor(theme.textPrimary);
impl_->preedit->SetCaretColor(tokens.accent);
impl_->preedit->SetTextAlignment(DWRITE_TEXT_ALIGNMENT_LEADING);
impl_->preedit->SetTextLayoutPadding({5.0f, 0.0f, 5.0f, 0.0f});
decorationTopDip_ = 0.0f;
decorationWidthDip_ = 0.0f;
std::wstring decorationPath;
if (package)
{
decorationTopDip_ = static_cast<float>(package->decorationTopDip);
decorationWidthDip_ = static_cast<float>(package->decorationWidthDip);
if (!package->preview.empty())
{
decorationPath =
skinsRoot + L"\\" + string_to_wstring(package->id) + L"\\" + string_to_wstring(package->preview);
}
}
if (decorationPath.empty() || decorationTopDip_ <= 0.0f)
{
impl_->decoration->SetHeight(0.0f);
impl_->decoration->ClearWidth();
impl_->decoration->SetSource(L"");
impl_->decoration->SetMargin({0.0f, 0.0f, 0.0f, 0.0f});
}
else
{
impl_->decoration->SetSource(decorationPath);
impl_->decoration->SetHeight(decorationTopDip_);
if (decorationWidthDip_ > 0.0f)
{
impl_->decoration->SetWidth(decorationWidthDip_);
}
impl_->decoration->SetStretch(msimeui::ImageStretch::Uniform);
impl_->decoration->SetMargin({0.0f, 0.0f, 0.0f, -(kShadowPadTop + kDecorationCardOverlap)});
}
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);
impl_->card->SetPadding(tokens.containerPad);
impl_->card->SetMinWidth(kCandidateMinWidthDip);
}
}
std::uint64_t CandidatePresenter::FillItemsFromUi()
{
const Global::CandidatePageSnapshotPtr page = Global::LoadCandidatePageSnapshot();
std::vector<msimeui::CandidateList::Item> items;
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 (GetConfiguredCandidateTranslationsEnabled())
item.translation = string_to_wstring(view.translation);
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;
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_, &current) && 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_);
// 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, kCandidateMinWidthDip + 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(kCandidateMinWidthDip);
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)(kCandidateMinWidthDip, maxW - kShadowPadLeft - kShadowPadRight);
stickyCardWidthDip_ = (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)(kCandidateMinWidthDip, 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);
}
// 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);
}
}
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;