From 9aba1a44ea2f1130cecb11d7aabe3ceff0cad072 Mon Sep 17 00:00:00 2001 From: Jia Sui Date: Fri, 25 Sep 2026 09:46:05 +0800 Subject: [PATCH] =?UTF-8?q?refactor(server):=20=E7=BB=84=E5=90=88=E6=81=A2?= =?UTF-8?q?=E5=A4=8D=E5=B8=A7=E7=BB=9F=E4=B8=80=E8=B5=B0=E5=80=99=E9=80=89?= =?UTF-8?q?=E9=A1=B5=E5=8F=91=E5=B8=83=E5=8A=A9=E6=89=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 组合恢复帧的候选发布此前只在注释里按「退格撤销」解释,但条件挂在 composition_restored 上,实际覆盖的是每一帧:退格撤销、Ctrl+Backspace 段删除、Ctrl+方向段跳转。段删除与段跳转同样改了 raw / 光标前缀,旧代码 只发帧不重建页面,页面上留着按键前的候选与选择。 - 把发布逻辑抽成 PublishRestoredCompositionCandidates,注释写明它服务 的是「每一帧组合恢复」而不是只有撤销,以及两条收起规则(raw 为空、 前缀为空)各自的理由;抽取不改行为。 - 一次性高亮的消费随之落到助手内部,仍然每帧消费一次,不跨帧泄漏。 --- server/src/ipc/event_listener.cpp | 118 +++++++++++++++--------------- 1 file changed, 60 insertions(+), 58 deletions(-) diff --git a/server/src/ipc/event_listener.cpp b/server/src/ipc/event_listener.cpp index 58bff117..b9323928 100644 --- a/server/src/ipc/event_listener.cpp +++ b/server/src/ipc/event_listener.cpp @@ -4300,6 +4300,64 @@ std::wstring BuildWubiCommitAndContinuePayload(const std::wstring &text) return std::to_wstring(kWubiCompleteCodeLength) + L"\t" + text; } +// Bring the candidate page in step with the CompositionRestored frame just sent. +// TSF applies that payload without touching its candidate presenter, so the page +// on screen has to follow here -- and that covers every frame the reply branch +// sends, not just the retraction: the unit Backspace deletion shortens the raw +// and the Ctrl+arrow unit jump moves the caret prefix, and both would otherwise +// leave the pre-key page (and its selection) on screen. +// - the raw is gone: nothing to show. The selected segments may still be on +// screen (a segment Backspace emptied the raw but kept the word), where TSF +// deliberately leaves its presenter alone because ending it would send +// HideCandidateWnd and reset this very composition; and a Backspace that +// ended the word must never fall through to a rebuild that would publish the +// empty-input fallback candidate; +// - the caret prefix is empty: hide too (R4, the same rule as the ShowCandidate +// task and the caret-arrow path); +// - otherwise rebuild from the engine -- the old page holds the pre-frame +// items -- and re-highlight the restored pick. +void PublishRestoredCompositionCandidates(uint64_t client_id, uint64_t activation_epoch) +{ + // One-shot: consume it even when this outcome hides instead of rebuilding, so + // a position recorded on a page that no longer exists cannot leak into a + // later frame. + const GlobalIme::RestoredSelectionHighlight restored_highlight = + GlobalIme::composition.take_restored_selection_highlight(); + if (GlobalIme::composition.raw_input_with_cases.empty()) + { + HideCandidateWindowAndDropItems(); + return; + } + if (FanyImeIpc::IsCaretPrefixEmpty(g_inputSession->prefix_end(), + g_inputSession->get_pinyin_sequence_with_cases().size())) + { + HideCandidateWindowAndDropItems(); + return; + } + + PrepareCandidateList(client_id, activation_epoch); + if (restored_highlight.absolute_index >= 0) + { + // A retraction re-highlights the item the user had picked on the rebuilt + // page: no frequency update ran during the creating word, so a page + // rebuilt for the same prefix still holds the same items in the same + // order. A page for another prefix (the suffix was edited between the + // pick and the retraction) does not, and the recorded position would land + // on an unrelated candidate -- apply it only after the prefixes match. + const std::string rebuilt_page_prefix = FanyImeIpc::NormalizeCandidatePagePrefix( + g_inputSession->get_pinyin_sequence_with_cases(), g_inputSession->prefix_end()); + auto &ui = Global::candidate_ui; + if (rebuilt_page_prefix == restored_highlight.page_prefix && ui.item_total_count > 0 && ui.page_size > 0) + { + const int position = std::min(restored_highlight.absolute_index, ui.item_total_count - 1); + ui.page_index = position / ui.page_size; + ui.selected_index_in_page = position % ui.page_size; + RefreshCandidatePageUi(false); + } + } + RequestShowCandidateWindow(); +} + /** * @brief * @@ -4783,69 +4841,13 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques // describes the state the key left behind -- restored, unchanged, or // one character shorter when the caret deletion in // ApplyCompositionEditKey ran -- which is what the hold applies in - // every outcome. - const GlobalIme::RestoredSelectionHighlight restored_highlight = - GlobalIme::composition.take_restored_selection_highlight(); + // every outcome. The candidate page follows it below. Global::MsgTypeToTsf = Global::DataFromServerMsgType::CompositionRestored; Global::candidate_ui.selected_text = BuildCreateWordPipePayload(GlobalIme::composition.raw_input_with_cases, GlobalIme::composition.creating_word.word) + L'\t' + std::to_wstring(GlobalIme::composition.caret_position); SendCurrentDataToClient(client_id, activation_epoch, request_id); - if (GlobalIme::composition.raw_input_with_cases.empty()) - { - // The spelling is gone. Two states land here: the raw was already - // empty while the selected segments stayed on screen (a segment - // Backspace deleted the last unit, R3), or this Backspace deleted - // the last character and ended the creating word -- the payload - // above tells the client to cancel from that empty state. TSF - // leaves its candidate presenter alone in the first state (ending - // it would send HideCandidateWnd, which resets this very - // composition), so the window is taken down here in both, and the - // second must not fall through to a rebuild that would publish - // the empty-input fallback candidate. - HideCandidateWindowAndDropItems(); - } - else if (FanyImeIpc::IsCaretPrefixEmpty(g_inputSession->prefix_end(), - g_inputSession->get_pinyin_sequence_with_cases().size())) - { - // The caret may have jumped to the start of the raw (segment - // edits and Ctrl+arrow land here too): an empty prefix must not - // wake a candidate window -- same rule as the ShowCandidate - // task and the caret-arrow Hide path. - HideCandidateWindowAndDropItems(); - } - else - { - // The window may have been taken down while the caret stood at - // an empty prefix (caret-driven resegmentation), and even when - // it stayed up the page still holds the pre-retraction items. - // Rebuild from the engine and ask for the window explicitly, - // mirroring the caret-arrow RebuildFromEngine path. - PrepareCandidateList(client_id, activation_epoch); - if (restored_highlight.absolute_index >= 0) - { - // A retraction re-highlights the item the user had picked on - // the rebuilt page: no frequency update ran during the - // creating word, so a page rebuilt for the same prefix still - // holds the same items in the same order. A page for another - // prefix (the suffix was edited between the pick and the - // retraction) does not, and the recorded position would land - // on an unrelated candidate -- apply it only after the - // prefixes match. - const std::string rebuilt_page_prefix = FanyImeIpc::NormalizeCandidatePagePrefix( - g_inputSession->get_pinyin_sequence_with_cases(), g_inputSession->prefix_end()); - auto &ui = Global::candidate_ui; - if (rebuilt_page_prefix == restored_highlight.page_prefix && ui.item_total_count > 0 && - ui.page_size > 0) - { - const int position = std::min(restored_highlight.absolute_index, ui.item_total_count - 1); - ui.page_index = position / ui.page_size; - ui.selected_index_in_page = position % ui.page_size; - RefreshCandidatePageUi(false); - } - } - RequestShowCandidateWindow(); - } + PublishRestoredCompositionCandidates(client_id, activation_epoch); } else if (GlobalSettings::getTsfPreeditStyle() == GlobalSettings::TsfPreeditStyle::Pinyin) {