refactor(server): 组合恢复帧统一走候选页发布助手

组合恢复帧的候选发布此前只在注释里按「退格撤销」解释,但条件挂在
composition_restored 上,实际覆盖的是每一帧:退格撤销、Ctrl+Backspace
段删除、Ctrl+方向段跳转。段删除与段跳转同样改了 raw / 光标前缀,旧代码
只发帧不重建页面,页面上留着按键前的候选与选择。

- 把发布逻辑抽成 PublishRestoredCompositionCandidates,注释写明它服务
  的是「每一帧组合恢复」而不是只有撤销,以及两条收起规则(raw 为空、
  前缀为空)各自的理由;抽取不改行为。
- 一次性高亮的消费随之落到助手内部,仍然每帧消费一次,不跨帧泄漏。
This commit is contained in:
Jia Sui committed 2026-09-25 09:46:05 +08:00
1 parent a596f8b1bc
commit 9aba1a44ea
1 file changed
+60 -58
+60 -58
View File
@@ -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)
{