From 7802777d06ad4d0adb728ec32a0a846235544798 Mon Sep 17 00:00:00 2001 From: fanlusky <1730976608@qq.com> Date: Sun, 27 Sep 2026 09:00:19 +0800 Subject: [PATCH 1/6] =?UTF-8?q?fix(server):=20=E8=8B=B1=E6=96=87=E6=A8=A1?= =?UTF-8?q?=E5=BC=8F=E6=9F=A5=E8=AF=A2=E8=BF=94=E5=9B=9E=E5=89=8D=E4=B8=8D?= =?UTF-8?q?=E5=86=8D=E7=BB=98=E5=88=B6=E7=A9=BA=E5=80=99=E9=80=89=E9=A1=B5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 专用英文模式在词典查询返回前会发布一个空页。原先照常请求显示,卡片先缩成只剩 预编辑行,查询结果回来后再撑开,每次按键都会闪一下,D2D 与 WebView2 都受影响。 英文 worker 对非空输入一定会回应(无匹配时回退为原样输入),回应本身会请求显示, 因此查询进行中、页面为空时跳过这次显示请求。选词仍按已绘制的页面代次等待, 不会上屏未显示的内容。 --- server/src/english/english_ime.cpp | 5 +++++ server/src/english/english_ime.h | 3 +++ server/src/ipc/event_listener.cpp | 18 ++++++++++++++++++ 3 files changed, 26 insertions(+) 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..b727da6a 100644 --- a/server/src/ipc/event_listener.cpp +++ b/server/src/ipc/event_listener.cpp @@ -231,6 +231,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 +250,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)) { From 5fc9485abf4f7a69d686bea42dbffe73a27a125b Mon Sep 17 00:00:00 2001 From: fanlusky <1730976608@qq.com> Date: Sun, 27 Sep 2026 09:00:35 +0800 Subject: [PATCH 2/6] =?UTF-8?q?fix(server):=20D2D=20=E5=80=99=E9=80=89?= =?UTF-8?q?=E7=AA=97=E6=8C=89=E7=A7=BB=E5=8A=A8=E6=96=B9=E5=90=91=E5=86=B3?= =?UTF-8?q?=E5=AE=9A=E7=A7=BB=E7=AA=97=E4=B8=8E=E5=91=88=E7=8E=B0=E7=9A=84?= =?UTF-8?q?=E5=85=88=E5=90=8E?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DWM 分别合成窗口移动和交换链呈现,窗口可见时总有一帧只完成了其中一步。原先 总是先移窗后呈现:卡片在屏幕右缘变窄时要向右挪,上一帧更宽的画面会先出现在新 位置,越过屏幕边缘落到右侧扩展屏上,下一帧再弹回来。 现在按轴分别处理:向左或向上移动的轴先移窗再呈现,向右或向下移动的轴先呈现再 移窗,这样中间帧不会超出新旧两张卡片覆盖的范围。DPI override 提前到调整窗口 尺寸之前,因为 WM_SIZE 里已经会呈现一次。 --- server/src/window/candidate_presenter.cpp | 26 ++++++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/server/src/window/candidate_presenter.cpp b/server/src/window/candidate_presenter.cpp index ea96dbab..a8c5e124 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 @@ -972,16 +973,35 @@ 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); + // DWM applies the window move and the swap-chain present independently, so + // on a visible host one frame always shows one of them stale. Moving first + // put the previous card at the new origin: at the right screen edge a card + // that narrowed moves right, and the previous, wider picture spilled past + // the edge onto the next monitor before snapping back. Per axis, the stale + // frame stays inside the old and new cards when a host moving left/up moves before + // the present and one moving right/down moves after it. + 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, widthPx, heightPx, 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 From 444b015a022a46cc1951cae92a78a6f6c452c9a4 Mon Sep 17 00:00:00 2001 From: fanlusky <1730976608@qq.com> Date: Sun, 27 Sep 2026 09:00:47 +0800 Subject: [PATCH 3/6] =?UTF-8?q?fix(server):=20=E5=80=99=E9=80=89=E8=AF=91?= =?UTF-8?q?=E6=96=87=E6=94=B9=E4=B8=BA=E8=B7=A8=E9=A1=B5=E7=BC=93=E5=AD=98?= =?UTF-8?q?=EF=BC=8C=E6=8D=A2=E9=A1=B5=E6=97=B6=E4=B8=8D=E5=86=8D=E5=85=88?= =?UTF-8?q?=E4=B8=A2=E5=90=8E=E8=A1=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 译文原先只是当前页的查询结果:每次按键换页、页面为空(英文模式两次按键之间) 或一次输入结束都会清空。新页第一帧因此不带任何译文,行宽先缩回去,查询返回后 再撑开,候选框在两帧之间来回改变大小。 现在 g_candidate_translation_glosses 按 TranslationIdentity 缓存,跨页、跨组合 保留,新页第一帧直接带上已知译文。上限 2048 条;关闭译文或翻译服务商开关变化 时清空;权威查询确认没有译文的词会从缓存中剔除。 --- server/src/ipc/event_listener.cpp | 42 +++++++++++++++++++++++++------ 1 file changed, 34 insertions(+), 8 deletions(-) diff --git a/server/src/ipc/event_listener.cpp b/server/src/ipc/event_listener.cpp index b727da6a..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 在高亮候选有多条译义时,把候选框整个换成那几条译义,让空格/ // 数字键像选普通候选一样选一条上屏。输入串一个字都没动,所以退出这个子模式时把原来的 @@ -1334,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; @@ -1366,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"); } @@ -4270,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); @@ -5221,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; From 3a7beb18abe39d6f1277ffbce52dbefd1ee77d0f Mon Sep 17 00:00:00 2001 From: fanlusky <1730976608@qq.com> Date: Sun, 27 Sep 2026 09:00:47 +0800 Subject: [PATCH 4/6] =?UTF-8?q?fix(server):=20=E5=90=8C=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E8=BE=93=E5=85=A5=E5=86=85=E5=80=99=E9=80=89=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E5=8F=AA=E5=A2=9E=E4=B8=8D=E5=87=8F?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 译文、云/AI 候选、混输英文等内容是陆续到达的,每一批都让卡片在两帧之间改变 宽高。每一帧本身画得完整,但尺寸来回变化看起来就是闪烁。 同一次组合内卡片只增大;自然尺寸低于保留尺寸的 70% 时(页面确实变小了)才跟着 收回,候选窗隐藏后重新开始。D2D 给 card 设置 MinWidth/MinHeight 后重新排版; WebView2 在更新内容的同一个 JS 任务里给 #realContainer 和 #measureContainer 设置最小宽高,绘制前就是最终尺寸,原生区域测量的也是同一个盒子。两端的收回比例 常量互相注明了对应关系。 --- server/src/webview2/windows_webview2.cpp | 40 +++++++++++++++++ server/src/window/candidate_presenter.cpp | 53 ++++++++++++++++++++--- server/src/window/candidate_presenter.h | 3 ++ 3 files changed, 90 insertions(+), 6 deletions(-) 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; }; From dcf1b6e2d3bd5d3fce41fe6b8ad3fd5c651d4fb6 Mon Sep 17 00:00:00 2001 From: fanlusky <1730976608@qq.com> Date: Sun, 27 Sep 2026 09:06:49 +0800 Subject: [PATCH 5/6] =?UTF-8?q?fix(server):=20D2D=20=E5=80=99=E9=80=89?= =?UTF-8?q?=E7=AA=97=E9=9A=90=E8=97=8F=E6=97=B6=E6=B8=85=E7=A9=BA=E4=BA=A4?= =?UTF-8?q?=E6=8D=A2=E9=93=BE=EF=BC=8C=E9=81=BF=E5=85=8D=E9=87=8D=E6=96=B0?= =?UTF-8?q?=E6=98=BE=E7=A4=BA=E6=97=B6=E9=97=AA=E5=87=BA=E4=B8=8A=E4=B8=80?= =?UTF-8?q?=E5=BC=A0=E5=8D=A1=E7=89=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Hide() 只把宿主窗口 cloak 起来,交换链里还留着最后一帧。下次显示时先呈现、再 取消 cloak,但 DWM 可能在拿到新帧之前就先合成了已取消 cloak 的窗口,于是有一帧 在新位置画出了上一张卡片。上一张卡片较宽时,新的窄卡片贴屏幕右缘,位置更靠右, 旧画面就会越过边缘落到右侧扩展屏上。删光输入后重新打字、或上屏后开始下一次输入 时最容易出现。 隐藏前先呈现一帧透明画面,交换链里不再留下旧卡片。同时在 candidate-d2d place 诊断日志里补上最终宿主位置、先移动到的位置、卡片像素矩形和显示器工作区。 --- server/src/window/candidate_presenter.cpp | 40 ++++++++++++++++++++--- server/src/window/candidate_presenter.h | 3 ++ 2 files changed, 38 insertions(+), 5 deletions(-) diff --git a/server/src/window/candidate_presenter.cpp b/server/src/window/candidate_presenter.cpp index bcd564da..bcf8d87e 100644 --- a/server/src/window/candidate_presenter.cpp +++ b/server/src/window/candidate_presenter.cpp @@ -1016,11 +1016,13 @@ void CandidatePresenter::PlaceAndShow(POINT caret, float widthDip, float heightD // 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, + widthPx, heightPx, x, y, firstX, firstY, onScreen, x + cardLeftPx, y + cardTopPx, cardWidthPx, monitor.left, + monitor.top, monitor.right, monitor.bottom); } void CandidatePresenter::ShowFromGlobalState() @@ -1158,6 +1160,13 @@ 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; @@ -1214,6 +1223,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 418f33f1..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); From 6a172d448dd3388f58bc50798d169b88dc70e861 Mon Sep 17 00:00:00 2001 From: fanlusky <1730976608@qq.com> Date: Sun, 27 Sep 2026 09:13:15 +0800 Subject: [PATCH 6/6] =?UTF-8?q?fix(server):=20D2D=20=E5=80=99=E9=80=89?= =?UTF-8?q?=E5=AE=BF=E4=B8=BB=E7=AA=97=E5=8F=A3=E4=B8=8D=E5=86=8D=E8=B6=8A?= =?UTF-8?q?=E8=BF=87=E5=BD=93=E5=89=8D=E6=98=BE=E7=A4=BA=E5=99=A8=E5=B7=A5?= =?UTF-8?q?=E4=BD=9C=E5=8C=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 卡片本身已经被夹在工作区内,但宿主窗口还带着阴影内边距和 64px 取整余量,贴近 右缘或下缘时会伸到相邻显示器上。DWM 分别应用窗口移动和交换链呈现,而且先后不 固定,某一帧可能把新卡片配上旧的宿主原点(或反过来),落在卡片外侧的内容就闪 到了旁边的屏幕上。诊断日志显示每次放置时卡片都在屏幕内(右缘 3837 < 3840), 但宿主一直延伸到 3927 左右。 宿主的宽高现在截到工作区的右缘和下缘,不管 DWM 怎么配对,都不会在光标所在显示器 之外画出任何东西。卡片完整保留,只裁掉伸出边缘的那部分阴影。移窗与呈现的先后 排序保留,它只决定哪种中间帧更常见。 --- server/src/window/candidate_presenter.cpp | 26 ++++++++++++++--------- 1 file changed, 16 insertions(+), 10 deletions(-) diff --git a/server/src/window/candidate_presenter.cpp b/server/src/window/candidate_presenter.cpp index bcf8d87e..35e7fc93 100644 --- a/server/src/window/candidate_presenter.cpp +++ b/server/src/window/candidate_presenter.cpp @@ -991,13 +991,19 @@ void CandidatePresenter::PlaceAndShow(POINT caret, float widthDip, float heightD const FLOAT dpiOverride = scale * 96.0f; impl_->window->SetDpiOverride(dpiOverride); impl_->resources.SetDpiOverride(dpiOverride); - // DWM applies the window move and the swap-chain present independently, so - // on a visible host one frame always shows one of them stale. Moving first - // put the previous card at the new origin: at the right screen edge a card - // that narrowed moves right, and the previous, wider picture spilled past - // the edge onto the next monitor before snapping back. Per axis, the stale - // frame stays inside the old and new cards when a host moving left/up moves before - // the present and one moving right/down moves after it. + // 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 = @@ -1005,7 +1011,7 @@ void CandidatePresenter::PlaceAndShow(POINT caret, float widthDip, float heightD 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, widthPx, heightPx, SWP_NOACTIVATE | SWP_SHOWWINDOW); + SetWindowPos(hwnd_, HWND_TOPMOST, firstX, firstY, hostWidthPx, hostHeightPx, SWP_NOACTIVATE | SWP_SHOWWINDOW); impl_->resources.EnsureForComposition(hwnd_); Present(); if (firstX != x || firstY != y) @@ -1021,8 +1027,8 @@ void CandidatePresenter::PlaceAndShow(POINT caret, float widthDip, float heightD 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, - widthPx, heightPx, x, y, firstX, firstY, onScreen, x + cardLeftPx, y + cardTopPx, cardWidthPx, monitor.left, - monitor.top, monitor.right, monitor.bottom); + hostWidthPx, hostHeightPx, x, y, firstX, firstY, onScreen, x + cardLeftPx, y + cardTopPx, cardWidthPx, + monitor.left, monitor.top, monitor.right, monitor.bottom); } void CandidatePresenter::ShowFromGlobalState()