Merge pull request #483 from fanlusky/fix/candidate-flicker

fix(server): 减少候选框闪烁与右缘越界
This commit is contained in:
Lu Fan(SonnyCalcr) authored and GitHub committed 2026-09-27 09:34:03 +08:00
commit 49748ea9bc
6 files changed
+217 -22

No files matched your search

+5
View File
@@ -171,6 +171,11 @@ void Stop()
g_translation_callback = {};
}
bool IsRunning()
{
return g_running;
}
void OnInputChanged(const std::string &input, bool dedicated_mode, size_t mixed_min_prefix)
{
{
+3
View File
@@ -33,6 +33,9 @@ using TranslationCallback = std::function<void(std::vector<TranslationResult> re
void Start(const std::string &db_path, ApplyCallback apply_callback, TranslationCallback translation_callback = {});
void Stop();
// While running, every non-empty dedicated-mode input is answered through the
// apply callback, even when the dictionary has no match.
bool IsRunning();
void OnInputChanged(const std::string &input, bool dedicated_mode = false, size_t mixed_min_prefix = 2);
void Clear();
bool IsCurrent(const std::string &input, uint64_t generation, bool dedicated_mode = false);
+52 -8
View File
@@ -102,8 +102,15 @@ bool g_english_input_mode = false;
// WebView2 candidate HWND — hosts (games) draw via ITfUIElementSink instead.
bool g_activate_uiless = false;
bool g_session_uiless = false;
// Glosses are a cache keyed by TranslationIdentity, not the current page's
// results: the next keystroke's page mostly repeats the same words, and
// rebuilding it without their glosses first shrank every row, then grew it back
// once the lookup answered. Bounded like the cloud cache, and dropped whenever
// the provider set changes (the target language is already in the identity).
constexpr size_t kMaxCandidateTranslationGlosses = 2048;
std::unordered_map<std::string, std::string> g_candidate_translation_glosses;
std::string g_candidate_translation_signature;
std::string g_candidate_translation_scope;
// 副候选框:Ctrl+Enter 在高亮候选有多条译义时,把候选框整个换成那几条译义,让空格/
// 数字键像选普通候选一样选一条上屏。输入串一个字都没动,所以退出这个子模式时把原来的
@@ -231,6 +238,18 @@ void ApplyUiLessFromPacket(const FanyImeNamedpipeData &pipe_data)
// task thread.
constexpr ULONGLONG kCandidateHideBacklogMs = 24;
// Dedicated English mode publishes an empty page while its dictionary query is
// in flight (see PrepareCandidateList). Painting that page shrank the card to
// the bare preedit, and ApplyEnglishCandidates grew it back a moment later — a
// collapse-and-expand flash on every keystroke. The English worker answers every
// non-empty input (no match becomes the raw fallback), and that answer requests
// its own show, so the empty in-between page is never worth painting.
bool IsEnglishQueryPendingOnEmptyPage()
{
return g_english_input_mode && Global::candidate_ui.items.empty() && g_inputSession &&
!g_inputSession->get_pinyin_sequence_with_cases().empty() && EnglishIme::IsRunning();
}
void RequestShowCandidateWindow()
{
if (IsUiLessMode() || !::global_hwnd)
@@ -238,6 +257,12 @@ void RequestShowCandidateWindow()
CAND_DIAG_LOGF(L"show request skipped uiless={} hwnd_present={}", IsUiLessMode(), ::global_hwnd != nullptr);
return;
}
if (IsEnglishQueryPendingOnEmptyPage())
{
CAND_DIAG_LOGF(L"show request deferred to english query raw_units={}",
GlobalIme::composition.raw_input_with_cases.size());
return;
}
bool expected = false;
if (!g_candidate_show_msg_pending.compare_exchange_strong(expected, true))
{
@@ -1316,17 +1341,30 @@ void PrepareCandidateTranslationRequest()
// 译文页不再查译文。已经取出的 glosses 也不能清,退出子模式后原候选还要用。
return;
}
if (!enabled)
{
g_candidate_translation_glosses.clear();
}
if (!enabled || ui.items.empty())
{
if (!g_candidate_translation_signature.empty() || !g_candidate_translation_glosses.empty())
// An empty page (English mode between keystrokes, or the end of a
// composition) only cancels the pending lookups; the cache stays for
// the next page.
if (!g_candidate_translation_signature.empty())
{
g_candidate_translation_signature.clear();
g_candidate_translation_glosses.clear();
EnglishIme::ClearTranslations();
CloudTranslation::Clear();
}
return;
}
const std::string scope = fmt::format("{}|{}|{}", GetConfiguredTencentTmt().enabled,
GetConfiguredCustomTranslation().enabled, GetConfiguredNiuTrans().enabled);
if (scope != g_candidate_translation_scope)
{
g_candidate_translation_scope = scope;
g_candidate_translation_glosses.clear();
}
std::vector<EnglishIme::TranslationQuery> queries;
std::string signature;
@@ -1348,7 +1386,6 @@ void PrepareCandidateTranslationRequest()
if (signature == g_candidate_translation_signature)
return;
g_candidate_translation_signature = std::move(signature);
g_candidate_translation_glosses.clear();
CloudTranslation::Clear();
EnglishIme::RequestTranslations(std::move(queries), GetConfiguredTencentTmt().target_language == "en");
}
@@ -4252,19 +4289,25 @@ void ApplyCandidateTranslations(std::vector<EnglishIme::TranslationResult> resul
(g_inputSession && g_inputSession->current_scheme_type() == SchemeType::JapaneseRomaji))
return;
if (!merge)
g_candidate_translation_glosses.clear();
std::vector<EnglishIme::TranslationQuery> misses;
for (auto &result : results)
{
std::string gloss = std::move(result.gloss);
if (gloss.empty() && !merge)
gloss = CloudTranslation::LookupCache(result.key, result.direction);
const std::string identity = TranslationIdentity({result.key, result.direction});
if (!gloss.empty())
g_candidate_translation_glosses[TranslationIdentity({result.key, result.direction})] = std::move(gloss);
{
if (g_candidate_translation_glosses.size() >= kMaxCandidateTranslationGlosses &&
g_candidate_translation_glosses.find(identity) == g_candidate_translation_glosses.end())
g_candidate_translation_glosses.clear();
g_candidate_translation_glosses[identity] = std::move(gloss);
}
else if (!merge)
{
// The authoritative lookup found nothing: drop what an earlier
// configuration may have cached for this word.
g_candidate_translation_glosses.erase(identity);
const bool cloud_translatable = result.direction == EnglishIme::TranslationDirection::EnglishToChinese
? CloudTranslation::IsCloudTranslatableEnglish(result.key)
: CloudTranslation::IsCloudTranslatableChinese(result.key);
@@ -5203,8 +5246,9 @@ void ClearState()
ClearCandidateUiOwner();
UpdateCloudInput("");
UpdateEnglishInput("");
// The gloss cache survives the composition: the next one starts from the
// same common words, and its first frame should already carry them.
g_candidate_translation_signature.clear();
g_candidate_translation_glosses.clear();
g_translation_candidates_active = false;
g_translation_saved_items.clear();
g_translation_saved_page_index = 0;
+40
View File
@@ -1825,6 +1825,43 @@ html:not(.msime-hover-armed) #realContainer.hover-active .cand:not(.first):hover
})();
)";
// Late additions to the page (translations, cloud/AI/English merges) resize the
// card between two frames of one composition, which reads as flicker even
// though each frame is complete. Within a composition the card only grows, and
// shrinks back only when its natural size drops below kStickyShrinkRatio of the
// kept one (the page really got smaller). 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) ends the
// composition. Mirrors kStickyCardShrinkRatio in the D2D candidate presenter.
constexpr wchar_t kStickyCandidateCardScript[] = LR"(
window.MsimeStickyCandidateCard = function (reset) {
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 kept = window.__msimeStickyCard || {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 = {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();
@@ -2163,6 +2200,9 @@ void UpdateHtmlContentWithJavaScript(ComPtr<ICoreWebView2> webview, const std::w
script.append(GetConfiguredCandidateWindowPreeditStyle() == "empty" ? L"false" : L"true");
script.append(L"); }\n");
script.append(L"if (window.SetPreeditCaret) { window.SetPreeditCaret(); }\n");
script.append(kStickyCandidateCardScript);
script.append(newContent.empty() ? L"window.MsimeStickyCandidateCard(true);\n"
: L"window.MsimeStickyCandidateCard(false);\n");
script.append(L"if (window.CheckContentTruncation) { window.CheckContentTruncation(); }\n");
if (diagnosticsEnabled)
{
+111 -14
View File
@@ -26,6 +26,7 @@
#include "engine/core/word_item.h"
#include <d2d1.h>
#include <dwmapi.h>
#include <windowsx.h>
#include <algorithm>
@@ -155,6 +156,16 @@ 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
@@ -972,25 +983,52 @@ void CandidatePresenter::PlaceAndShow(POINT caret, float widthDip, float heightD
{
y = monitor.top + 2 - cardTopPx;
}
SetWindowPos(hwnd_, HWND_TOPMOST, x, y, widthPx, heightPx, SWP_NOACTIVATE | SWP_SHOWWINDOW);
// Rendering must use the same scale the window was just sized with: the ui
// 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().
// 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;
SetWindowPos(hwnd_, HWND_TOPMOST, firstX, firstY, hostWidthPx, hostHeightPx, SWP_NOACTIVATE | SWP_SHOWWINDOW);
impl_->resources.EnsureForComposition(hwnd_);
Present();
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=({},{})",
scaleSource == CandidateScaleSource::RdpForeground ? L"rdp-foreground" : L"monitor", scale,
GetDpiForWindow(hwnd_), GetDpiForSystem(), GetSystemMetrics(SM_REMOTESESSION) ? 1 : 0, caret.x,
caret.y, widthPx, heightPx);
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()
@@ -1056,12 +1094,41 @@ void CandidatePresenter::ShowFromGlobalState(POINT 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;
impl_->root->InvalidateMeasure();
const msimeui::SizeF measured = impl_->root->MeasureInLayout({maxW, maxH});
float widthDip = (std::max)(measured.width, kCandidateMinWidthDip + kShadowPadLeft + kShadowPadRight);
float heightDip = (std::max)(measured.height, 36.0f);
impl_->root->InvalidateArrange();
impl_->root->ArrangeInLayout({0.0f, 0.0f, widthDip, heightDip});
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 composition, and the eye
// reads that as flicker even though every frame is complete. Within one
// composition 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). Hide() starts the next composition from the natural size.
if (impl_->card)
{
impl_->card->SetMinWidth(kCandidateMinWidthDip);
impl_->card->ClearMinHeight();
}
layoutRoot();
if (impl_->card)
{
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_))
{
@@ -1099,9 +1166,18 @@ void CandidatePresenter::Hide()
{
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;
stickyCardWidthDip_ = 0.0f;
stickyCardHeightDip_ = 0.0f;
SetWindowPos(hwnd_, nullptr, 0, Global::INVALID_Y, 0, 0,
SWP_NOSIZE | SWP_NOZORDER | SWP_NOACTIVATE | SWP_SHOWWINDOW);
}
@@ -1153,6 +1229,27 @@ void CandidatePresenter::Present()
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{};
+6
View File
@@ -36,6 +36,9 @@ class CandidatePresenter
// unset (scale == 0) value makes PlaceAndShow resolve it itself.
void PlaceAndShow(POINT caret, float widthDip, float heightDip, float cardLeftDip, float cardTopDip,
const ResolvedCandidateScale &scale = {});
// Hide leaves the host cloaked; clearing it keeps the next uncloak from
// briefly showing the previous card.
void PresentEmptyFrame();
void ArmHoverIfPointerMoved();
void CommitItem(size_t pageIndex);
void ShowItemContextMenu(size_t pageIndex, POINT clientPoint);
@@ -59,5 +62,8 @@ class CandidatePresenter
int lastHostHeightPx_ = 0;
float lastLayoutWidthDip_ = 0.0f;
float lastLayoutHeightDip_ = 0.0f;
// Largest card size kept for the current composition; reset by Hide().
float stickyCardWidthDip_ = 0.0f;
float stickyCardHeightDip_ = 0.0f;
int wheelDeltaAccumulator_ = 0;
};