diff --git a/server/src/webview2/windows_webview2.cpp b/server/src/webview2/windows_webview2.cpp index 4ca9864d..25429d6f 100644 --- a/server/src/webview2/windows_webview2.cpp +++ b/server/src/webview2/windows_webview2.cpp @@ -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 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) { diff --git a/server/src/window/candidate_presenter.cpp b/server/src/window/candidate_presenter.cpp index a8c5e124..bcd564da 100644 --- a/server/src/window/candidate_presenter.cpp +++ b/server/src/window/candidate_presenter.cpp @@ -156,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 @@ -1076,12 +1086,41 @@ void CandidatePresenter::ShowFromGlobalState(POINT caret) const HalfScreenDipLimits limits = ApplyScaleToHalfScreenLimits(caret, scale.scale); const float maxW = limits.maxWidthDip > 1.0 ? static_cast(limits.maxWidthDip) : 480.0f; const float maxH = limits.maxHeightDip > 1.0 ? static_cast(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_)) { @@ -1122,6 +1161,8 @@ void CandidatePresenter::Hide() 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); } diff --git a/server/src/window/candidate_presenter.h b/server/src/window/candidate_presenter.h index b3140121..418f33f1 100644 --- a/server/src/window/candidate_presenter.h +++ b/server/src/window/candidate_presenter.h @@ -59,5 +59,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; };