Files
msime-windows/server/src/webview2/windows_webview2_candidate.cpp
T
fanlusky dde06afd7f feat(server): 候选框显示翻页箭头并接入设置页预览
- D2D:横排在候选右侧多一窄列,竖排在最后一行下方、「‹」与序号左对齐;开关或排列方式变化时重建场景
- WebView2:页面按 data-page-arrows 显示箭头,点击经 candidatePage 翻页
- 箭头点击走 WM_PAGE_CANDIDATE_ARROW,不受鼠标滚轮翻页开关影响;页面快照带上一页 / 下一页可用状态
- 两个设置窗口宿主下发内置皮肤的 page_arrows,外部皮肤条目带解析后的 pageArrows
- 安装包把 skins/default 以 onlyifdoesntexist 放到数据目录,升级不覆盖用户改过的清单
2026-09-30 12:42:25 +08:00

1219 lines
54 KiB
C++

// 候选窗 WebView:内容与测量区的脚本更新、悬停锁与粘性卡片脚本、候选槽位增量更新、
// 测量用 bounds,以及候选窗 controller 的创建与页面消息处理。
#include "webview2/windows_webview2_internal.h"
#include "engine/contracts/webview/validator.h"
#include "webview2/candidate_window_template.h"
#include "config/ime_config.h"
#include "defines/defines.h"
#include "defines/globals.h"
#include "global/globals.h"
#include "ipc/ipc.h"
#include "ipc/candidate_ui_action_policy.h"
#include "utils/common_utils.h"
#include "utils/ime_utils.h"
#include "utils/webview_utils.h"
#include "utils/window_utils.h"
#include "window/candidate_wheel_paging.h"
#include <nlohmann/json.hpp>
#include <algorithm>
#include <cmath>
#include <filesystem>
#include <functional>
#include <string>
#include <utility>
#include <vector>
using namespace windows_webview2_detail;
// Legacy floor kept for 100% DPI; high-DPI reserves are computed from DIPs below.
constexpr int candidateBoundExtraFloorPx = 1000;
static std::wstring EscapeForJsTemplateLiteral(const std::wstring &text)
{
// Content is injected as an untagged JavaScript template literal. Kaomoji
// commonly contain `\`, backticks (e.g. `( -'`-)`), and `${`; any of those
// will terminate or invalidate the literal and the DOM update is dropped.
std::wstring escaped;
escaped.reserve(text.size() + 16);
for (size_t index = 0; index < text.size(); ++index)
{
const wchar_t ch = text[index];
if (ch == L'\\')
{
escaped += L"\\\\";
}
else if (ch == L'`')
{
escaped += L"\\`";
}
else if (ch == L'$' && index + 1 < text.size() && text[index + 1] == L'{')
{
escaped += L"\\${";
++index;
}
else
{
escaped += ch;
}
}
return escaped;
}
namespace
{
constexpr UINT_PTR kCandidateHoverArmTimerId = 21;
constexpr int kCandidateHoverArmDistancePx = 2;
POINT g_candidate_hover_cursor{};
bool g_candidate_hover_armed = false;
ULONGLONG g_candidate_hover_disarm_tick = 0;
// Leftover wheel travel below one notch, carried across `candidateWheel`
// messages. The page reports deltas in WHEEL_DELTA units, so this is the same
// accumulator the D2D presenter keeps for WM_MOUSEWHEEL.
int g_candidate_wheel_accumulator = 0;
// CSS :hover is applied by Chromium when the HWND sits under a still
// cursor. That does not go through mousemove JS. Kill those paints until
// the physical screen cursor actually moves after the card is up.
constexpr wchar_t kInstallCandidateHoverLockScript[] = LR"(
(function () {
if (!document.getElementById('msime-hover-lock-style')) {
const style = document.createElement('style');
style.id = 'msime-hover-lock-style';
style.textContent = `
html:not(.msime-hover-armed) #realContainer .cand:not(.first):hover,
html:not(.msime-hover-armed) #realContainer.hover-active .cand:not(.first):hover {
background-color: transparent !important;
background-image: none !important;
box-shadow: none !important;
outline: none !important;
}
`;
document.documentElement.appendChild(style);
}
document.documentElement.classList.remove('msime-hover-armed');
const container = document.getElementById('realContainer');
if (container) {
container.classList.remove('hover-active');
}
if (!window.__msimeBlockSynthHover) {
window.__msimeBlockSynthHover = true;
document.addEventListener('mousemove', function (event) {
if (!document.documentElement.classList.contains('msime-hover-armed')) {
if (event.movementX !== 0 || event.movementY !== 0) {
document.documentElement.classList.add('msime-hover-armed');
const liveContainer = document.getElementById('realContainer');
if (liveContainer) {
liveContainer.classList.add('hover-active');
}
window.chrome.webview.postMessage(JSON.stringify({type:'candidatePointerArmed'}));
return;
}
window.chrome.webview.postMessage(JSON.stringify({type:'candidatePointerMotion'}));
event.stopImmediatePropagation();
}
}, true);
}
})();
)";
// Late additions to the page (translations, cloud/AI/English merges) resize the
// card between two frames of the same input, which reads as flicker even
// though each frame is complete. For one input (preedit + caret + page, the
// key) the card only grows, and shrinks back only when its natural size drops
// below kStickyShrinkRatio of the kept one (the page really got smaller). A new
// key — another keystroke, caret move or page turn — is a different page and
// starts from its natural size; carrying the old size over leaves blank rows.
// Runs in the same task as the content update, so the frame is laid out once
// with the final size; the native region measures the same box and follows. An
// empty update (hide) resets. Mirrors kStickyCardShrinkRatio in the D2D
// candidate presenter.
constexpr wchar_t kStickyCandidateCardScript[] = LR"(
window.MsimeStickyCandidateCard = function (reset, key) {
const kStickyShrinkRatio = 0.7;
const box = document.getElementById('realContainer');
if (!box) return;
const measure = document.getElementById('measureContainer');
const boxes = measure ? [box, measure] : [box];
boxes.forEach(function (b) { b.style.minWidth = ''; b.style.minHeight = ''; });
if (reset) { window.__msimeStickyCard = null; return; }
box.style.boxSizing = 'border-box';
const rect = box.getBoundingClientRect();
const previous = window.__msimeStickyCard;
const kept = previous && previous.key === key ? previous : {width: 0, height: 0};
const follow = function (keptValue, natural) {
return natural >= keptValue || natural < keptValue * kStickyShrinkRatio ? natural : keptValue;
};
let width = follow(kept.width, rect.width);
const maxWidth = Number.parseFloat(box.style.maxWidth);
if (maxWidth > 0) width = Math.min(width, maxWidth);
const height = follow(kept.height, rect.height);
window.__msimeStickyCard = {key: key, width: width, height: height};
if (width > rect.width + 0.5 || height > rect.height + 0.5) {
boxes.forEach(function (b) {
b.style.minWidth = width + 'px';
b.style.minHeight = height + 'px';
});
}
};
)";
VOID CALLBACK CandidateHoverArmTimerProc(HWND, UINT, UINT_PTR, DWORD)
{
MaybeArmCandidatePointerHover();
}
} // namespace
void DisarmCandidatePointerHover()
{
g_candidate_hover_armed = false;
g_candidate_hover_disarm_tick = GetTickCount64();
if (!GetPhysicalCursorPos(&g_candidate_hover_cursor))
{
GetCursorPos(&g_candidate_hover_cursor);
}
CAND_WEBVIEW_TRACE_LOGF(L"candidate-hover disarm tick={} native_cursor=({},{}) shown={}",
g_candidate_hover_disarm_tick, g_candidate_hover_cursor.x, g_candidate_hover_cursor.y,
::is_global_wnd_cand_shown);
if (!::global_hwnd)
{
return;
}
if (::is_global_wnd_cand_shown)
{
SetTimer(::global_hwnd, kCandidateHoverArmTimerId, 32, CandidateHoverArmTimerProc);
}
else
{
KillTimer(::global_hwnd, kCandidateHoverArmTimerId);
}
}
void MaybeArmCandidatePointerHover()
{
if (g_candidate_hover_armed || !::is_global_wnd_cand_shown || !webviewCandWnd)
{
if (!::is_global_wnd_cand_shown && ::global_hwnd)
{
KillTimer(::global_hwnd, kCandidateHoverArmTimerId);
}
return;
}
POINT now{};
if (!GetPhysicalCursorPos(&now))
{
GetCursorPos(&now);
}
const int dx = now.x - g_candidate_hover_cursor.x;
const int dy = now.y - g_candidate_hover_cursor.y;
if (dx * dx + dy * dy < kCandidateHoverArmDistancePx * kCandidateHoverArmDistancePx)
{
return;
}
g_candidate_hover_armed = true;
CAND_WEBVIEW_TRACE_LOGF(L"candidate-hover arm tick={} baseline=({},{}) native_cursor=({},{}) delta=({},{})",
GetTickCount64(), g_candidate_hover_cursor.x, g_candidate_hover_cursor.y, now.x, now.y, dx,
dy);
if (::global_hwnd)
{
KillTimer(::global_hwnd, kCandidateHoverArmTimerId);
}
webviewCandWnd->ExecuteScript(LR"(document.documentElement.classList.add('msime-hover-armed');
const c = document.getElementById('realContainer');
if (c) { c.classList.add('hover-active'); })",
nullptr);
}
void ResetContainerHoverCandWnd(ComPtr<ICoreWebView2> webview)
{
DisarmCandidatePointerHover();
if (webview != nullptr)
{
webview->ExecuteScript(kInstallCandidateHoverLockScript, nullptr);
}
}
constexpr wchar_t kEnsureApplyCandidateFrameScript[] = LR"(
window.ApplyCandidateFrame = function (payload) {
const container = document.getElementById('realContainer');
const parent = document.getElementById('realContainerParent');
if (!container) return {width: 0, height: 0};
if (payload.resetHover && window.ClearState) window.ClearState();
if (parent && payload.applyMargins) {
if (payload.marginTop != null) parent.style.marginTop = payload.marginTop + 'px';
if (payload.marginLeft != null) parent.style.marginLeft = payload.marginLeft + 'px';
}
if (window.SetCandidatePreeditVisible) {
window.SetCandidatePreeditVisible(payload.preeditVisible !== false);
}
const preedit = container.querySelector('.pinyin .text');
if (preedit) {
preedit.textContent = payload.preedit || '';
if (window.SetPreeditCaret) window.SetPreeditCaret();
}
const items = Array.isArray(payload.items) ? payload.items : [];
const wrappers = container.querySelectorAll('.row-wrapper');
let lastVisible = -1;
wrappers.forEach(function (wrapper, index) {
if (index < items.length && String(items[index] || '')) lastVisible = index;
});
wrappers.forEach(function (wrapper, index) {
const html = index < items.length ? String(items[index] || '') : '';
wrapper.style.display = html ? '' : 'none';
// Unused rows stay in the DOM, so :last-child cannot find the last shown row.
wrapper.classList.toggle('last-visible', index === lastVisible);
const cand = wrapper.querySelector('.cand');
if (!cand) return;
let slot = cand.querySelector('.cand-content');
if (!slot) {
const text = cand.querySelector('.text') || cand;
slot = document.createElement('span');
slot.className = 'cand-content';
const num = text.querySelector('.num, .cand-no');
if (num) {
while (num.nextSibling) text.removeChild(num.nextSibling);
text.appendChild(slot);
} else {
text.appendChild(slot);
}
}
slot.innerHTML = html;
});
if (window.SetCandidateSelection) {
window.SetCandidateSelection(payload.selected || 0);
}
void container.offsetWidth;
const rect = container.getBoundingClientRect();
// scrollWidth must be part of this: rect/offsetWidth report the clipped box
// once .container{overflow-x:hidden} bites, so without it the native region
// is built from a width narrower than what is actually painted.
return {
width: Math.max(rect.width, container.offsetWidth || 0, container.scrollWidth || 0) + 1,
height: Math.max(rect.height, container.offsetHeight || 0) + 1,
rw: rect.width, ow: container.offsetWidth || 0,
sw: container.scrollWidth || 0, cw: container.clientWidth || 0
};
};
// Do NOT force white-space:nowrap on .container/.row-wrapper here. The skin CSS
// wraps the inline-block candidates at --msime-max-width-dip; pinning them to a
// single line makes the card outgrow that cap, and .container's overflow-x:hidden
// then slices the last candidate mid-glyph instead of flowing it onto a new row.
)";
std::pair<double, double> g_last_candidate_slot_measured_size{};
bool g_candidate_slot_api_installed = false;
namespace
{
bool g_candidate_slot_update_inflight = false;
std::wstring g_candidate_slot_update_pending;
std::function<void()> g_candidate_slot_update_pending_complete;
bool g_candidate_slot_update_pending_content_only = false;
void SubmitCandidateSlotScript(ComPtr<ICoreWebView2> webview, const std::wstring &payload,
std::function<void()> onComplete, bool contentOnly);
void FlushPendingCandidateSlotUpdate(ComPtr<ICoreWebView2> webview)
{
if (g_candidate_slot_update_pending.empty())
{
return;
}
std::wstring pending = std::move(g_candidate_slot_update_pending);
std::function<void()> complete = std::move(g_candidate_slot_update_pending_complete);
const bool pendingContentOnly = g_candidate_slot_update_pending_content_only;
g_candidate_slot_update_pending.clear();
g_candidate_slot_update_pending_complete = nullptr;
g_candidate_slot_update_pending_content_only = false;
SubmitCandidateSlotScript(webview, pending, std::move(complete), pendingContentOnly);
}
void SubmitCandidateSlotScript(ComPtr<ICoreWebView2> webview, const std::wstring &payload,
std::function<void()> onComplete, bool contentOnly)
{
g_candidate_slot_update_inflight = true;
if (!contentOnly)
{
DisarmCandidatePointerHover();
}
std::wstring script;
script.reserve(payload.size() + 2048);
if (!g_candidate_slot_api_installed)
{
script.append(kEnsureApplyCandidateFrameScript);
g_candidate_slot_api_installed = true;
}
script.append(L"window.msimeCandidateDiagnostics = ");
script.append(GetConfiguredDiagnosticLogEnabled() ? L"true;\n" : L"false;\n");
script.append(L"window.ApplyCandidateFrame(");
script.append(payload);
script.append(L");");
const HRESULT submitHr = webview->ExecuteScript(
script.c_str(),
Callback<ICoreWebView2ExecuteScriptCompletedHandler>([webview, onComplete](HRESULT errorCode,
LPCWSTR result) -> HRESULT {
if (FAILED(errorCode))
{
CAND_WEBVIEW_TRACE_LOGF(L"candidate-slot execute-failed hr={:#x}", static_cast<unsigned>(errorCode));
}
else if (result)
{
g_last_candidate_slot_measured_size = ParseDivSize(result);
// Numbers only (no candidate text): shows painted vs reported width.
CAND_WEBVIEW_TRACE_LOGF(L"candidate-slot measured_dip=({:.2f},{:.2f}) raw={}",
g_last_candidate_slot_measured_size.first,
g_last_candidate_slot_measured_size.second, result);
}
g_candidate_slot_update_inflight = false;
if (!g_candidate_slot_update_pending.empty())
{
FlushPendingCandidateSlotUpdate(webview);
return S_OK;
}
if (onComplete)
{
onComplete();
}
return S_OK;
}).Get());
if (FAILED(submitHr))
{
g_candidate_slot_update_inflight = false;
CAND_WEBVIEW_TRACE_LOGF(L"candidate-slot submit-failed hr={:#x}", static_cast<unsigned>(submitHr));
if (onComplete)
{
onComplete();
}
}
}
void UpdateCandidateSlotsWithJavaScript(ComPtr<ICoreWebView2> webview, const std::wstring &payload,
std::function<void()> onComplete, bool contentOnly)
{
if (!webview)
{
if (onComplete)
{
onComplete();
}
return;
}
if (g_candidate_slot_update_inflight)
{
g_candidate_slot_update_pending = payload;
g_candidate_slot_update_pending_complete = std::move(onComplete);
g_candidate_slot_update_pending_content_only = contentOnly;
return;
}
SubmitCandidateSlotScript(webview, payload, std::move(onComplete), contentOnly);
}
std::wstring BuildCandidateSlotPayloadJson(const std::wstring &commaSeparated, bool contentOnly)
{
std::vector<std::wstring> words = SplitCandidateTemplatePayload(commaSeparated);
nlohmann::json payload;
payload["preedit"] = words.empty() ? std::string{} : wstring_to_string(words[0]);
payload["items"] = nlohmann::json::array();
// Preserve slot indices: do not compress out empty tokens.
// The WebView DOM renderer expects wrapper<->slot index alignment.
uint16_t nonEmptyMask = 0;
for (size_t i = 1; i < words.size(); ++i)
{
if (i <= 9 && !words[i].empty())
{
nonEmptyMask |= static_cast<uint16_t>(1u) << static_cast<uint16_t>(i - 1);
}
payload["items"].push_back(wstring_to_string(words[i]));
}
if (nonEmptyMask == 0 && !words.empty())
{
CAND_DIAG_LOGF(L"candidate-slot payload all-empty words_sz={} content_only={} preedit_len={}", words.size(),
contentOnly ? 1 : 0, words[0].size());
}
payload["selected"] = Global::candidate_ui.selected_index_in_page;
payload["preeditVisible"] = GetConfiguredCandidateWindowPreeditStyle() != "empty";
payload["applyMargins"] = !contentOnly;
payload["resetHover"] = !contentOnly;
if (!contentOnly)
{
payload["marginTop"] = Global::MarginTop;
payload["marginLeft"] = Global::MarginLeft;
}
return string_to_wstring(payload.dump());
}
} // namespace
std::pair<double, double> LastCandidateSlotMeasuredSize()
{
return g_last_candidate_slot_measured_size;
}
void UpdateHtmlContentWithJavaScript(ComPtr<ICoreWebView2> webview, const std::wstring &newContent)
{
UpdateHtmlContentWithJavaScript(webview, newContent, nullptr);
}
void UpdateHtmlContentWithJavaScript(ComPtr<ICoreWebView2> webview, const std::wstring &newContent,
std::function<void()> onComplete)
{
if (!webview)
{
if (onComplete)
{
onComplete();
}
return;
}
const std::wstring escaped = EscapeForJsTemplateLiteral(newContent);
std::wstring script;
script.reserve(escaped.length() + 2200);
const bool diagnosticsEnabled = GetConfiguredDiagnosticLogEnabled();
script.append(L"window.msimeCandidateDiagnostics = ");
script.append(diagnosticsEnabled ? L"true;\n" : L"false;\n");
script.append(L"document.getElementById('realContainer').innerHTML = `");
script.append(escaped);
script.append(L"`;\n");
script.append(L"window.ClearState();\n");
script.append(kInstallCandidateHoverLockScript);
DisarmCandidatePointerHover();
script.append(L"var el = document.getElementById('realContainerParent');\n");
script.append(L"if (el) {\n");
script.append(L" el.style.marginTop = \"");
script.append(std::to_wstring(Global::MarginTop));
script.append(L"px\";\n");
script.append(L" el.style.marginLeft = \"");
script.append(std::to_wstring(Global::MarginLeft));
script.append(L"px\";\n");
script.append(L"}\n");
script.append(L"if (window.SetCandidateSelection) { window.SetCandidateSelection(");
script.append(std::to_wstring(Global::candidate_ui.selected_index_in_page));
script.append(L"); }\n");
script.append(L"if (window.SetCandidatePreeditVisible) { window.SetCandidatePreeditVisible(");
script.append(GetConfiguredCandidateWindowPreeditStyle() == "empty" ? L"false" : L"true");
script.append(L"); }\n");
script.append(L"if (window.SetPreeditCaret) { window.SetPreeditCaret(); }\n");
{
// 翻页箭头随模板一起被 innerHTML 换掉了,每帧重新标一次可用状态;皮肤关掉箭头时页面只是不显示它。
const Global::CandidatePageSnapshotPtr page = Global::LoadCandidatePageSnapshot();
script.append(L"if (window.SetCandidatePager) { window.SetCandidatePager(");
script.append(page->has_previous_page ? L"true, " : L"false, ");
script.append(page->has_next_page ? L"true" : L"false");
script.append(L"); }\n");
}
script.append(kStickyCandidateCardScript);
if (newContent.empty())
{
script.append(L"window.MsimeStickyCandidateCard(true);\n");
}
else
{
const nlohmann::json stickyKey =
wstring_to_string(GetPreeditWithCaretMarker()) + "#" + std::to_string(Global::candidate_ui.page_index);
script.append(L"window.MsimeStickyCandidateCard(false, ");
script.append(string_to_wstring(stickyKey.dump()));
script.append(L");\n");
}
script.append(L"if (window.CheckContentTruncation) { window.CheckContentTruncation(); }\n");
if (diagnosticsEnabled)
{
script.append(LR"(
(function () {
window.__msimeCandidateDomRevision = (window.__msimeCandidateDomRevision || 0) + 1;
const revision = window.__msimeCandidateDomRevision;
requestAnimationFrame(() => {
window.chrome.webview.postMessage(JSON.stringify({type:'candidateFrameProbe',data:{
revision:revision,stage:1,performanceMs:performance.now()
}}));
requestAnimationFrame(() => {
window.chrome.webview.postMessage(JSON.stringify({type:'candidateFrameProbe',data:{
revision:revision,stage:2,performanceMs:performance.now()
}}));
});
});
})();
)");
}
if (!onComplete)
{
const HRESULT submitHr = webview->ExecuteScript(script.c_str(), nullptr);
if (FAILED(submitHr))
{
CAND_WEBVIEW_TRACE_LOGF(L"candidate-script submit-failed hr={:#x} callback=false",
static_cast<unsigned>(submitHr));
}
return;
}
const HRESULT submitHr = webview->ExecuteScript(
script.c_str(),
Callback<ICoreWebView2ExecuteScriptCompletedHandler>([onComplete](HRESULT errorCode, LPCWSTR) -> HRESULT {
if (FAILED(errorCode))
{
CAND_WEBVIEW_TRACE_LOGF(L"candidate-script execute-failed hr={:#x} callback=true",
static_cast<unsigned>(errorCode));
}
onComplete();
return S_OK;
}).Get());
if (FAILED(submitHr))
{
CAND_WEBVIEW_TRACE_LOGF(L"candidate-script submit-failed hr={:#x} callback=true",
static_cast<unsigned>(submitHr));
}
}
void PrepareCandidateWebViewBoundsForMeasure(HWND hwnd)
{
if (!webviewControllerCandWnd || !hwnd)
{
return;
}
RECT bounds{};
GetClientRect(hwnd, &bounds);
// MarginLeft can push the card nearly a full max-width across the viewport
// near a screen edge. Reserve that room in DIPs so 200% scaling still has
// enough CSS pixels; a fixed 1000 physical-px pad is only ~500 DIP at 200%.
FLOAT scale = GetWebViewRasterizationScale(hwnd);
if (scale <= 0.0f)
{
scale = 1.0f;
}
const int extraRightDip = ::CANDIDATE_WINDOW_MAX_WIDTH_DIP + ::CANDIDATE_SHADOW_PAD_LEFT +
::CANDIDATE_SHADOW_PAD_RIGHT + ::POP_UP_WND_WIDTH;
const int extraBottomDip =
::CANDIDATE_WINDOW_HEIGHT + ::CANDIDATE_SHADOW_PAD_TOP + ::CANDIDATE_SHADOW_PAD_BOTTOM + ::POP_UP_WND_HEIGHT;
const int extraRightPx = (std::max)(candidateBoundExtraFloorPx, static_cast<int>(std::ceil(extraRightDip * scale)));
const int extraBottomPx =
(std::max)(candidateBoundExtraFloorPx, static_cast<int>(std::ceil(extraBottomDip * scale)));
bounds.right += extraRightPx;
bounds.bottom += extraBottomPx;
const HRESULT hr = webviewControllerCandWnd->put_Bounds(bounds);
DIAG_LOGF(L"ui-webview-bounds phase=measure client_plus_reserve={}x{} extra_px=({},{}) scale={:.3f} hr={:#x}",
bounds.right - bounds.left, bounds.bottom - bounds.top, extraRightPx, extraBottomPx,
static_cast<double>(scale), static_cast<unsigned>(hr));
}
void SyncCandidateWebViewBoundsToHost(HWND hwnd)
{
if (!webviewControllerCandWnd || !hwnd)
{
return;
}
// Keep the measure-time reserve instead of shrinking the viewport to the
// host client. MarginLeft pushes the card towards the right of the viewport
// near a screen edge, so an exactly-sized viewport leaves the card less
// room than it was measured with and it wraps into a taller card that the
// window region then clips.
PrepareCandidateWebViewBoundsForMeasure(hwnd);
const HRESULT hr = webviewControllerCandWnd->NotifyParentWindowPositionChanged();
DIAG_LOGF(L"ui-webview-bounds phase=sync-parent notify_hr={:#x}", static_cast<unsigned>(hr));
}
void LogCandidateLayoutSnapshot(const wchar_t *stage)
{
(void)stage;
}
//
//
// 候选窗口 webview
//
//
void UpdateMeasureContentWithJavaScript(ComPtr<ICoreWebView2> webview, const std::wstring &newContent,
std::function<void()> onComplete)
{
if (webview == nullptr)
{
if (onComplete)
{
onComplete();
}
return;
}
const std::wstring escaped = EscapeForJsTemplateLiteral(newContent);
std::wstring script;
script.reserve(escaped.length() + 256);
script.append(L"document.getElementById('measureContainer').innerHTML = `");
script.append(escaped);
script.append(L"`;\n");
script.append(L"if (window.SetCandidatePreeditVisible) { window.SetCandidatePreeditVisible(");
script.append(GetConfiguredCandidateWindowPreeditStyle() == "empty" ? L"false" : L"true");
script.append(L"); }\n");
// 测量容器也要带上翻页箭头,量出的卡片尺寸才包含它;不带参数表示不改真实容器里的可用状态。
script.append(L"if (window.SetCandidatePager) { window.SetCandidatePager(); }\n");
if (!onComplete)
{
webview->ExecuteScript(script.c_str(), nullptr);
return;
}
webview->ExecuteScript(
script.c_str(), Callback<ICoreWebView2ExecuteScriptCompletedHandler>([onComplete](HRESULT, LPCWSTR) -> HRESULT {
onComplete();
return S_OK;
}).Get());
}
void UpdateMeasureContentWithJavaScript(ComPtr<ICoreWebView2> webview, const std::wstring &newContent)
{
UpdateMeasureContentWithJavaScript(webview, newContent, nullptr);
}
void DisableMouseForAWhileWhenShownCandWnd(ComPtr<ICoreWebView2> webview)
{
if (webview != nullptr)
{
std::wstring script = LR"(
if (window.mouseBlockTimeout) {
clearTimeout(window.mouseBlockTimeout);
}
document.documentElement.style.pointerEvents = "none";
window.mouseBlockTimeout = setTimeout(() => {
document.documentElement.style.pointerEvents = "auto";
window.mouseBlockTimeout = null;
}, 500);
)";
webview->ExecuteScript(script.c_str(), nullptr);
}
}
void InflateCandWnd(std::wstring &str)
{
InflateCandWnd(str, nullptr);
}
void InflateCandWnd(std::wstring &str, std::function<void()> onComplete)
{
std::wstring result = InflateCandidateTemplate(BodyStringCandWnd, str);
UpdateHtmlContentWithJavaScript(webviewCandWnd, result, std::move(onComplete));
}
void InflateCandWnd(std::wstring &str, std::function<void()> onComplete, bool contentOnly)
{
(void)contentOnly;
std::wstring result = InflateCandidateTemplate(BodyStringCandWnd, str);
UpdateHtmlContentWithJavaScript(webviewCandWnd, result, std::move(onComplete));
}
void InflateMeasureDivCandWnd(std::wstring &str)
{
InflateMeasureDivCandWnd(str, nullptr);
}
void InflateMeasureDivCandWnd(std::wstring &str, std::function<void()> onComplete)
{
str.erase(std::remove(str.begin(), str.end(), L'\uE000'), str.end());
std::wstring result = InflateCandidateTemplate(::MeasureStringCandWnd, str);
UpdateMeasureContentWithJavaScript(webviewCandWnd, result, std::move(onComplete));
}
/**
* @brief Handle candidate window webview2 controller creation
*
* @param hwnd
* @param result
* @param controller
* @return HRESULT
*/
HRESULT OnControllerCreatedCandWnd( //
HWND hwnd, //
HRESULT result, //
ICoreWebView2Controller *controller //
)
{
CAND_DIAG_LOGF(L"webview controller callback hr={:#x} controller_present={} hwnd_valid={}",
static_cast<unsigned>(result), controller != nullptr, IsWindow(hwnd) != FALSE);
if (!controller || FAILED(result))
{
OnSmallWindowControllerSettled(FAILED(result) ? result : E_FAIL);
return E_FAIL;
}
webviewControllerCandWnd = controller;
const HRESULT getWebviewHr = webviewControllerCandWnd->get_CoreWebView2(webviewCandWnd.GetAddressOf());
if (!webviewCandWnd)
{
CAND_DIAG_LOGF(L"webview get_CoreWebView2 failed hr={:#x}", static_cast<unsigned>(getWebviewHr));
webviewControllerCandWnd.Reset();
OnSmallWindowControllerSettled(FAILED(getWebviewHr) ? getWebviewHr : E_FAIL);
return E_FAIL;
}
UpdateSmallWindowWebviewVisibility(hwnd, IsWindowVisible(hwnd) != FALSE);
// Configure WebView settings
ComPtr<ICoreWebView2Settings> settings;
if (SUCCEEDED(webviewCandWnd->get_Settings(&settings)))
{
settings->put_IsScriptEnabled(TRUE);
settings->put_AreDefaultScriptDialogsEnabled(FALSE);
settings->put_IsWebMessageEnabled(TRUE);
settings->put_AreHostObjectsAllowed(FALSE); // Since we only use WebMessages
settings->put_IsZoomControlEnabled(FALSE);
settings->put_IsStatusBarEnabled(FALSE);
settings->put_AreDefaultContextMenusEnabled(FALSE);
settings->put_AreDevToolsEnabled(FALSE);
// Try to disable browser accelerators (Ctrl+R, F5, etc.)
ComPtr<ICoreWebView2Settings3> settings3;
if (SUCCEEDED(settings->QueryInterface(IID_PPV_ARGS(&settings3))))
{
settings3->put_AreBrowserAcceleratorKeysEnabled(FALSE);
// settings3->put_IsPinchZoomEnabled(FALSE); // Unsupported in this header version
}
// Try to disable autofill and password saving
ComPtr<ICoreWebView2Settings5> settings5;
if (SUCCEEDED(settings->QueryInterface(IID_PPV_ARGS(&settings5))))
{
settings5->put_IsGeneralAutofillEnabled(FALSE);
settings5->put_IsPasswordAutosaveEnabled(FALSE);
}
// Try to disable built-in error pages for a cleaner UI
ComPtr<ICoreWebView2Settings6> settings6;
if (SUCCEEDED(settings->QueryInterface(IID_PPV_ARGS(&settings6))))
{
settings6->put_IsBuiltInErrorPageEnabled(FALSE);
}
}
webviewControllerCandWnd->put_ZoomFactor(1.0);
if (SUCCEEDED(controller->QueryInterface(IID_PPV_ARGS(&webviewController3CandWnd))))
{
// Let WebView2 track both monitor DPI and the user's accessibility text
// scale. Every native clip/layout conversion reads this same value.
const HRESULT detectHr = webviewController3CandWnd->put_ShouldDetectMonitorScaleChanges(TRUE);
double rasterizationScale = 0.0;
const HRESULT scaleHr = webviewController3CandWnd->get_RasterizationScale(&rasterizationScale);
const HRESULT eventHr = webviewController3CandWnd->add_RasterizationScaleChanged(
Callback<ICoreWebView2RasterizationScaleChangedEventHandler>([hwnd](ICoreWebView2Controller *sender,
IUnknown *) -> HRESULT {
if (!IsWindow(hwnd))
{
return S_OK;
}
ComPtr<ICoreWebView2Controller3> controller3;
double scale = 0.0;
const HRESULT hr = sender ? sender->QueryInterface(IID_PPV_ARGS(&controller3)) : E_POINTER;
const HRESULT scaleHr = SUCCEEDED(hr) ? controller3->get_RasterizationScale(&scale) : hr;
DIAG_LOGF(L"candidate rasterization scale changed scale={:.4f} native_scale={:.4f} hr={:#x}", scale,
static_cast<double>(GetWindowScale(hwnd)), static_cast<unsigned>(scaleHr));
InjectSurfaceViewportLimits(webviewCandWnd.Get(), hwnd);
if (::is_global_wnd_cand_shown && IsCandidateWebviewReady())
{
FineTuneWindow(hwnd);
}
return S_OK;
}).Get(),
&candidateRasterizationScaleChangedToken);
candidateRasterizationScaleChangedRegistered = SUCCEEDED(eventHr);
DIAG_LOGF(L"candidate rasterization scale initialized scale={:.4f} native_scale={:.4f} "
L"scale_hr={:#x} detect_hr={:#x} event_hr={:#x}",
rasterizationScale, static_cast<double>(GetWindowScale(hwnd)), static_cast<unsigned>(scaleHr),
static_cast<unsigned>(detectHr), static_cast<unsigned>(eventHr));
}
// Configure virtual host path
if (SUCCEEDED(webviewCandWnd->QueryInterface(IID_PPV_ARGS(&webview3CandWnd))))
{
const auto contractsPath =
std::filesystem::path(CommonUtils::get_ime_data_path_w()) / L"html" / L"webview2" / L"shared";
webview3CandWnd->SetVirtualHostNameToFolderMapping(L"msime-contracts", contractsPath.wstring().c_str(),
COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW);
const std::wstring assetPath = fmt::format( //
L"{}\\html\\webview2\\candwnd", //
CommonUtils::get_ime_data_path_w() //
);
// Assets mapping
webview3CandWnd->SetVirtualHostNameToFolderMapping( //
L"candwnd", //
assetPath.c_str(), //
COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_DENY_CORS //
); //
const std::wstring skinsPath = fmt::format( //
L"{}\\skins", //
CommonUtils::get_ime_data_path_w() //
);
const HRESULT skinsMappingHr = webview3CandWnd->SetVirtualHostNameToFolderMapping(
L"candidate-skins", skinsPath.c_str(), COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_ALLOW);
(void)skinsMappingHr;
(void)0;
}
// Set transparent background
if (SUCCEEDED(controller->QueryInterface(IID_PPV_ARGS(&webviewController2CandWnd))))
{
COREWEBVIEW2_COLOR backgroundColor = {0, 0, 0, 0};
webviewController2CandWnd->put_DefaultBackgroundColor(backgroundColor);
}
// Adjust to window size — keep the same DIP-based measure reserve used by
// FineTune so the first layout is not constrained by a narrow HWND.
PrepareCandidateWebViewBoundsForMeasure(hwnd);
// Navigate to HTML
if (HTMLStringCandWnd.empty())
{
PrepareHtmlForWnds();
}
HRESULT hr = webviewCandWnd->NavigateToString(HTMLStringCandWnd.c_str());
CAND_DIAG_LOGF(L"webview NavigateToString hr={:#x} html_chars={} skin={}", static_cast<unsigned>(hr),
HTMLStringCandWnd.size(), string_to_wstring(preparedCandidateSkin));
if (SUCCEEDED(hr))
{
loadedCandidateSkin = preparedCandidateSkin;
}
(void)0;
if (FAILED(hr))
{
ShowErrorMessage(hwnd, L"Failed to navigate to string.");
}
/* Debug console */
// webview->OpenDevToolsWindow();
webviewCandWnd->add_WebMessageReceived(
Microsoft::WRL::Callback<ICoreWebView2WebMessageReceivedEventHandler>(
[hwnd](ICoreWebView2 * /*sender*/, ICoreWebView2WebMessageReceivedEventArgs *args) -> HRESULT {
wil::unique_cotaskmem_string message;
HRESULT hr = args->TryGetWebMessageAsString(&message);
if (SUCCEEDED(hr) && message.get())
{
std::wstring msg(message.get());
// 解析 msg,执行相应操作
try
{
json::value val = json::parse(wstring_to_string(msg));
if (!metasequoia::webview::Validate(val, "client", "candidate"))
return S_OK;
std::string type = json::value_to<std::string>(val.at("type"));
if (type == "candidateFrameProbe")
{
const auto &data = val.at("data");
const int revision = json::value_to<int>(data.at("revision"));
const int stage = json::value_to<int>(data.at("stage"));
const double performanceMs = json::value_to<double>(data.at("performanceMs"));
RECT rect{};
GetWindowRect(hwnd, &rect);
CAND_WEBVIEW_TRACE_LOGF(
L"candidate-browser-frame revision={} stage={} performance_ms={:.3f} native_tick={} "
L"hwnd_rect=({},{},{}x{}) shown={} window_visible={}",
revision, stage, performanceMs, GetTickCount64(), rect.left, rect.top,
rect.right - rect.left, rect.bottom - rect.top, ::is_global_wnd_cand_shown,
IsWindowVisible(hwnd) != FALSE);
}
else if (type == "candidatePointerArmed")
{
g_candidate_hover_armed = true;
if (::global_hwnd)
{
KillTimer(::global_hwnd, kCandidateHoverArmTimerId);
}
CAND_WEBVIEW_TRACE_LOGF(L"candidate-hover arm source=dom-motion tick={}", GetTickCount64());
}
else if (type == "candidatePointerMotion")
{
// A WebView mousemove can be synthesized when the
// candidate HWND moves under a stationary cursor.
// The native screen-coordinate comparison arms only
// when the physical pointer itself really moved.
MaybeArmCandidatePointerHover();
}
else if (type == "candidatePointerProbe")
{
const auto &data = val.at("data");
POINT cursor{};
GetCursorPos(&cursor);
RECT rect{};
GetWindowRect(hwnd, &rect);
CAND_WEBVIEW_TRACE_LOGF(
L"candidate-pointer probe={} event_screen=({},{}) event_client=({},{}) "
L"movement=({},{}) js_armed={} native_cursor=({},{}) baseline=({},{}) "
L"native_armed={} hwnd=({},{},{}x{}) tick={}",
json::value_to<int>(data.at("probe")), json::value_to<int>(data.at("screenX")),
json::value_to<int>(data.at("screenY")), json::value_to<int>(data.at("clientX")),
json::value_to<int>(data.at("clientY")), json::value_to<int>(data.at("movementX")),
json::value_to<int>(data.at("movementY")), json::value_to<bool>(data.at("armed")),
cursor.x, cursor.y, g_candidate_hover_cursor.x, g_candidate_hover_cursor.y,
g_candidate_hover_armed, rect.left, rect.top, rect.right - rect.left,
rect.bottom - rect.top, GetTickCount64());
}
else if (type == "delete")
{
int idx = json::value_to<int>(val.at("data"));
if (FanyImeIpc::IsValidCandidateUiOneBasedIndex(idx))
{
PostMessage(::global_hwnd, WM_DELETE_CANDIDATE, idx, 0);
}
}
else if (type == "pin")
{
int idx = json::value_to<int>(val.at("data"));
if (FanyImeIpc::IsValidCandidateUiOneBasedIndex(idx))
{
PostMessage(::global_hwnd, WM_PIN_TO_TOP_CANDIDATE, idx, 0);
}
}
else if (type == "fixPosition")
{
int idx = json::value_to<int>(val.at("data").at("index"));
int position = json::value_to<int>(val.at("data").at("position"));
if (FanyImeIpc::IsValidCandidateUiOneBasedIndex(idx) && position >= 1 && position <= 5)
PostMessage(::global_hwnd, WM_FIX_CANDIDATE_POSITION, idx, position);
}
else if (type == "clearPosition")
{
int idx = json::value_to<int>(val.at("data"));
if (FanyImeIpc::IsValidCandidateUiOneBasedIndex(idx))
PostMessage(::global_hwnd, WM_CLEAR_CANDIDATE_POSITION, idx, 0);
}
else if (type == "candidateWheel")
{
const int delta = json::value_to<int>(val.at("data"));
if (!::is_global_wnd_cand_shown)
{
// The page can post a wheel message that was in
// flight when the candidate window went away.
g_candidate_wheel_accumulator = 0;
}
else
{
const CandidateWheel::PagingSteps steps = CandidateWheel::ConsumeWheelDelta(
g_candidate_wheel_accumulator, delta, WHEEL_DELTA);
// Same WM_PAGE_CANDIDATE route as the D2D window,
// which is also where the setting is checked.
if (steps.page_up > 0)
{
PostMessage(::global_hwnd, WM_PAGE_CANDIDATE, CANDIDATE_PAGE_PREVIOUS,
steps.page_up);
}
if (steps.page_down > 0)
{
PostMessage(::global_hwnd, WM_PAGE_CANDIDATE, CANDIDATE_PAGE_NEXT, steps.page_down);
}
}
}
else if (type == "candidatePage")
{
// 翻页箭头,与 D2D 窗口走同一条 WM_PAGE_CANDIDATE_ARROW。
// 方向已由契约限定为 previous / next。
if (::is_global_wnd_cand_shown)
{
const bool next = json::value_to<std::string>(val.at("data")) == "next";
PostMessage(::global_hwnd, WM_PAGE_CANDIDATE_ARROW,
next ? CANDIDATE_PAGE_NEXT : CANDIDATE_PAGE_PREVIOUS, 1);
}
}
else if (type == "contextMenuResize")
{
const auto &data = val.at("data");
const int width = json::value_to<int>(data.at("width"));
const int height = json::value_to<int>(data.at("height"));
int top_expansion = 0;
if (const auto *value = data.as_object().if_contains("topExpansion"))
top_expansion = (std::max)(0, json::value_to<int>(*value));
int left_expansion = 0;
if (const auto *value = data.as_object().if_contains("leftExpansion"))
left_expansion = (std::max)(0, json::value_to<int>(*value));
RECT window_rect{};
GetWindowRect(hwnd, &window_rect);
const FLOAT scale = GetWebViewRasterizationScale(hwnd);
const int current_width = static_cast<int>(window_rect.right - window_rect.left);
const int current_height = static_cast<int>(window_rect.bottom - window_rect.top);
const int top_expansion_px = static_cast<int>(std::ceil(top_expansion * scale));
const int left_expansion_px = static_cast<int>(std::ceil(left_expansion * scale));
SetWindowPos(hwnd, nullptr, window_rect.left - left_expansion_px,
window_rect.top - top_expansion_px,
(std::max)(current_width, static_cast<int>(std::ceil(width * scale))) +
left_expansion_px,
(std::max)(current_height, static_cast<int>(std::ceil(height * scale))) +
top_expansion_px,
SWP_NOZORDER | SWP_NOACTIVATE);
// FineTuneWindow clips the stable candidate host to
// the card. The context menu is allowed to use the
// temporarily expanded host and FineTuneWindow
// reapplies the tight region after it closes.
SetWindowRgn(hwnd, nullptr, TRUE);
RECT bounds{};
GetClientRect(hwnd, &bounds);
webviewControllerCandWnd->put_Bounds(bounds);
if (top_expansion > 0 || left_expansion > 0)
{
const std::wstring script = L"if(window.ApplyContextMenuTopExpansion)"
L"window.ApplyContextMenuTopExpansion(" +
std::to_wstring(top_expansion) +
L");if(window.ApplyContextMenuLeftExpansion)"
L"window.ApplyContextMenuLeftExpansion(" +
std::to_wstring(left_expansion) + L");";
webviewCandWnd->ExecuteScript(script.c_str(), nullptr);
}
}
else if (type == "contextMenuClosed")
{
const std::wstring preedit = GetConfiguredCandidateWindowPreeditStyle() == "empty"
? std::wstring{}
: GetPreeditWithCaretMarker();
std::wstring measurement = preedit + L"," + Global::CandidateString;
InflateMeasureDivCandWnd(measurement, [hwnd]() { FineTuneWindow(hwnd); });
}
else if (type == "candidate")
{
int idx = json::value_to<int>(val.at("data"));
if (FanyImeIpc::IsValidCandidateUiOneBasedIndex(idx))
{
PostMessage(::global_hwnd, WM_COMMIT_CANDIDATE, idx, 0);
}
}
else if (type == "contentTruncated")
{
// Candidate host is intentionally quarter-screen; the card is
// placed with CSS margins + SetWindowRgn. HTML truncation checks
// use clientWidth/Height, so margin+content past the right/bottom
// of the host looks "truncated" even when FineTune is correct.
// Never shrink the HWND or zero margins here — that parked the
// card at the host origin away from the caret.
if (::is_global_wnd_cand_shown)
{
const bool grew = HandleContentTruncatedMessage(hwnd, webviewCandWnd.Get(),
webviewControllerCandWnd.Get(), val,
g_last_content_truncation_cand_ms, 0);
if (grew)
{
// Host grew beyond the previous region; drop the clip so
// the next FineTune/clip-measure can reapply margins.
SetWindowRgn(hwnd, nullptr, TRUE);
CAND_DIAG_LOGF(L"candidate contentTruncated grew host; region cleared "
L"(margins kept) {}",
DescribeCandidateHostState());
}
else
{
CAND_DIAG_LOGF(L"candidate contentTruncated ignored (no shrink) {}",
DescribeCandidateHostState());
}
}
}
}
catch (const std::exception &)
{
// A malformed message from the page must not tear down the host.
return S_OK;
}
}
return S_OK;
})
.Get(),
nullptr);
webviewCandWnd->add_NavigationCompleted(
Microsoft::WRL::Callback<ICoreWebView2NavigationCompletedEventHandler>(
[hwnd](ICoreWebView2 *, ICoreWebView2NavigationCompletedEventArgs *args) -> HRESULT {
BOOL success = FALSE;
COREWEBVIEW2_WEB_ERROR_STATUS errorStatus = COREWEBVIEW2_WEB_ERROR_STATUS_UNKNOWN;
const HRESULT successHr = args->get_IsSuccess(&success);
const HRESULT statusHr = args->get_WebErrorStatus(&errorStatus);
CAND_DIAG_LOGF(L"webview navigation completed success={} success_hr={:#x} status_hr={:#x} "
L"web_error={} logical_shown={}",
success != FALSE, static_cast<unsigned>(successHr), static_cast<unsigned>(statusHr),
static_cast<int>(errorStatus), ::is_global_wnd_cand_shown);
if (success)
{
g_candidate_slot_api_installed = false;
NotifySmallWindowNavigationReady(candidateNavigationReady, L"candidate");
ApplyConfiguredCandidateAppearance();
InjectSurfaceViewportLimits(webviewCandWnd.Get(), hwnd);
webviewCandWnd->ExecuteScript(kInstallCandidateHoverLockScript, nullptr);
DisarmCandidatePointerHover();
}
else
{
(void)0;
if (::is_global_wnd_cand_shown)
SetCandidateHostCloaked(false);
}
if (success && ::is_global_wnd_cand_shown)
{
const std::wstring preedit = GetConfiguredCandidateWindowPreeditStyle() == "empty"
? std::wstring{}
: GetPreeditWithCaretMarker();
std::wstring str = preedit + L"," + Global::CandidateString;
InflateMeasureDivCandWnd(str, [hwnd]() {
if (!::is_global_wnd_cand_shown)
{
return;
}
FineTuneWindow(hwnd);
});
}
return S_OK;
})
.Get(),
nullptr);
webviewCandWnd->add_ProcessFailed(Microsoft::WRL::Callback<ICoreWebView2ProcessFailedEventHandler>(
[](ICoreWebView2 *, ICoreWebView2ProcessFailedEventArgs *args) -> HRESULT {
COREWEBVIEW2_PROCESS_FAILED_KIND kind =
COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED;
const HRESULT hr = args->get_ProcessFailedKind(&kind);
CAND_DIAG_LOGF(L"webview process failed kind={} hr={:#x}",
static_cast<int>(kind), static_cast<unsigned>(hr));
return S_OK;
})
.Get(),
nullptr);
OnSmallWindowControllerSettled(S_OK);
return S_OK;
}
/**
* @brief Handle candidate window webview2 environment creation
*
* @param hwnd
* @param result
* @param env
* @return HRESULT
*/
HRESULT OnEnvironmentCreated(HWND hwnd, HRESULT result, ICoreWebView2Environment *env)
{
(void)0;
if (FAILED(result) || !env)
{
ShowErrorMessage(hwnd, L"Failed to create WebView2 environment.");
return result;
}
// Create WebView2 controller
const HRESULT hr = env->CreateCoreWebView2Controller( //
hwnd, //
Microsoft::WRL::Callback<ICoreWebView2CreateCoreWebView2ControllerCompletedHandler>( //
[hwnd](HRESULT result, //
ICoreWebView2Controller *controller) -> HRESULT { //
return OnControllerCreatedCandWnd(hwnd, result, controller); //
}) //
.Get() //
); //
(void)0;
return hr;
}