diff --git a/server/src/english/english_ime.cpp b/server/src/english/english_ime.cpp index 26d2b6ea..2ec63b81 100644 --- a/server/src/english/english_ime.cpp +++ b/server/src/english/english_ime.cpp @@ -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) { { diff --git a/server/src/english/english_ime.h b/server/src/english/english_ime.h index 4f135882..3aa93e91 100644 --- a/server/src/english/english_ime.h +++ b/server/src/english/english_ime.h @@ -33,6 +33,9 @@ using TranslationCallback = std::function 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); diff --git a/server/src/ipc/event_listener.cpp b/server/src/ipc/event_listener.cpp index bc58f850..ed139b83 100644 --- a/server/src/ipc/event_listener.cpp +++ b/server/src/ipc/event_listener.cpp @@ -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 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 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 resul (g_inputSession && g_inputSession->current_scheme_type() == SchemeType::JapaneseRomaji)) return; - if (!merge) - g_candidate_translation_glosses.clear(); - std::vector 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; 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 ea96dbab..35e7fc93 100644 --- a/server/src/window/candidate_presenter.cpp +++ b/server/src/window/candidate_presenter.cpp @@ -26,6 +26,7 @@ #include "engine/core/word_item.h" #include +#include #include #include @@ -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_, ¤t) && current.top != Global::INVALID_Y && + SUCCEEDED(DwmGetWindowAttribute(hwnd_, DWMWA_CLOAKED, &cloaked, sizeof(cloaked))) && cloaked == 0; + const int firstX = onScreen && x > current.left ? current.left : x; + const int firstY = onScreen && y > current.top ? current.top : y; + 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(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_)) { @@ -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{}; diff --git a/server/src/window/candidate_presenter.h b/server/src/window/candidate_presenter.h index b3140121..1953d183 100644 --- a/server/src/window/candidate_presenter.h +++ b/server/src/window/candidate_presenter.h @@ -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; };