From 26354ce0f48a4166466f1a43e06cc2f7dec0bdc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=8F=A4=E6=98=8E=E5=9B=9B=E5=AD=A3?= <84919145+Komeiji-Shiki@users.noreply.github.com> Date: Sat, 17 Oct 2026 19:27:52 +0800 Subject: [PATCH] =?UTF-8?q?fix(server):=20=E9=80=89=E8=AF=8D=E6=9C=9F?= =?UTF-8?q?=E9=97=B4=E4=BF=9D=E6=8C=81=E5=80=99=E9=80=89=E9=A1=BA=E5=BA=8F?= =?UTF-8?q?=E5=92=8C=E9=AB=98=E4=BA=AE=E4=BD=8D=E7=BD=AE=20(#1022)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(server): 选词期间保持候选顺序和高亮位置 * fix(server): 收紧选词期间冻结候选顺序的触发与边界 - 鼠标操作改为界面线程记下页面代次、工作线程出队时认领,不再入队:排在悬停之前的异步结果也会被挡住,滚轮翻页任务的合并不再被打断 - 悬停只在指针停到某个候选上才算开始选词;右键按已画出的那一页认定 - 专用英文在候选窗显示前的方向键不算选词,答复照常换上候选并放行被扣下的显示 - 退出译文/日期子页恢复进入前的选词状态,而不是一律标记为已开始 - 选词后的翻页扩展按「词 + 拼音 + 来源」抵掉已显示的候选,没有新增时报告未变化 - 五处异步合并共用 CanMergeAsyncCandidates --------- Co-authored-by: fanlusky <1730976608@qq.com> --- server/src/global/globals.h | 9 +++ server/src/ipc/candidate_expansion_policy.h | 79 +++++++++++++++---- server/src/ipc/event_listener.cpp | 29 +++++++ server/src/ipc/event_listener.h | 3 + .../ipc/event_listener_async_candidates.cpp | 13 ++- server/src/ipc/event_listener_candidates.cpp | 8 +- server/src/ipc/event_listener_internal.h | 6 ++ server/src/ipc/event_listener_keys.cpp | 7 ++ server/src/ipc/event_listener_worker.cpp | 1 + server/src/window/candidate_presenter.cpp | 30 +++++++ server/src/window/candidate_presenter.h | 3 + .../src/test_candidate_expansion_policy.cpp | 60 ++++++++++++++ server/tests/src/test_candidate_ui_state.cpp | 21 +++++ 13 files changed, 246 insertions(+), 23 deletions(-) diff --git a/server/src/global/globals.h b/server/src/global/globals.h index 761ae25c..ed6da2d8 100644 --- a/server/src/global/globals.h +++ b/server/src/global/globals.h @@ -304,6 +304,8 @@ struct CandidateUiState int cur_page_max_word_len = 2; int cur_page_item_cnt = 8; bool is_num_out_of_range = false; + // 用户开始选词后保持候选顺序,直到输入变化后重新建立候选列表。 + bool selection_started = false; void set_items(std::vector new_items) { @@ -311,6 +313,7 @@ struct CandidateUiState item_total_count = static_cast(items.size()); page_index = 0; selected_index_in_page = 0; + selection_started = false; clear_page(); } @@ -329,6 +332,11 @@ struct CandidateUiState selected_index_in_page = 0; } + void begin_selection() + { + selection_started = item_total_count > 0; + } + bool move_selection(int offset) { if (page_size <= 0) @@ -343,6 +351,7 @@ struct CandidateUiState selected_index_in_page = 0; return false; } + begin_selection(); selected_index_in_page = std::clamp(selected_index_in_page, 0, count - 1); const int next = current_page_start() + selected_index_in_page + offset; if (next < 0 || next >= item_total_count) diff --git a/server/src/ipc/candidate_expansion_policy.h b/server/src/ipc/candidate_expansion_policy.h index 3e79eccf..6d15996b 100644 --- a/server/src/ipc/candidate_expansion_policy.h +++ b/server/src/ipc/candidate_expansion_policy.h @@ -4,34 +4,80 @@ #include "ipc/candidate_selection_policy.h" #include +#include +#include +#include #include #include namespace FanyImeIpc { +// 这些来源只活在界面列表里,会话扩展重建不出来。 +inline bool IsUiOnlyCandidateSource(CandidateSource source) +{ + switch (source) + { + case CandidateSource::CloudSuggestion: + case CandidateSource::AiSuggestion: + case CandidateSource::EnglishDictionary: + case CandidateSource::Emoji: + case CandidateSource::Kaomoji: + case CandidateSource::DateTime: + return true; + default: + return false; + } +} + // Session expansion rebuilds local candidates; asynchronous results only live in the UI list. // Quick phrases are rebuilt by the caller, so carry over only asynchronous sources, deduplicating // against the expanded list before applying the same ordering policy as worker callbacks. -inline void SetExpandedCandidatesKeepingPagePosition(Global::CandidateUiState &ui, std::vector expanded, +// Returns false when the list did not change. +inline bool SetExpandedCandidatesKeepingPagePosition(Global::CandidateUiState &ui, std::vector expanded, const EnglishPlacement &english_placement = {}) { + if (ui.selection_started) + { + // 翻页扩展只追加尚未显示的候选。引擎可能已经有新的重排结果,不能挪动用户正在选的项。 + // 扩展结果是整份本地候选,按「词 + 拼音 + 来源」逐条抵掉已显示的那些:同字不同音、 + // 快捷短语与词库同字都是各自独立的候选,不能只按字去重。界面独有的异步候选扩展里 + // 重建不出来,和非选词路径一样按字挡掉与它同字的本地候选。 + const auto identity = [](const WordItem &item) { + return item.word + '\x1f' + item.pinyin + '\x1f' + std::to_string(static_cast(item.source)); + }; + std::unordered_map shown; + std::unordered_set ui_only_words; + for (const auto &candidate : ui.items) + { + ++shown[identity(candidate)]; + if (IsUiOnlyCandidateSource(candidate.source)) + ui_only_words.insert(candidate.word); + } + const size_t size_before = ui.items.size(); + for (auto &candidate : expanded) + { + const auto it = shown.find(identity(candidate)); + if (it != shown.end() && it->second > 0) + { + --it->second; + continue; + } + if (ui_only_words.count(candidate.word) != 0) + continue; + ui.items.push_back(std::move(candidate)); + } + if (ui.items.size() == size_before) + return false; + ui.item_total_count = static_cast(ui.items.size()); + ui.clear_page(); + return true; + } for (const auto &candidate : ui.items) { - switch (candidate.source) - { - case CandidateSource::CloudSuggestion: - case CandidateSource::AiSuggestion: - case CandidateSource::EnglishDictionary: - case CandidateSource::Emoji: - case CandidateSource::Kaomoji: - case CandidateSource::DateTime: - if (std::none_of(expanded.begin(), expanded.end(), - [&](const WordItem &item) { return item.word == candidate.word; })) - expanded.push_back(candidate); - break; - default: - break; - } + if (IsUiOnlyCandidateSource(candidate.source) && + std::none_of(expanded.begin(), expanded.end(), + [&](const WordItem &item) { return item.word == candidate.word; })) + expanded.push_back(candidate); } NormalizeMixedCandidateOrder(expanded, 1, english_placement); const int current_page = ui.page_index; @@ -39,5 +85,6 @@ inline void SetExpandedCandidatesKeepingPagePosition(Global::CandidateUiState &u ui.set_items(std::move(expanded)); ui.page_index = current_page; ui.selected_index_in_page = current_selection; + return true; } } // namespace FanyImeIpc diff --git a/server/src/ipc/event_listener.cpp b/server/src/ipc/event_listener.cpp index a09fefe7..424c5c4e 100644 --- a/server/src/ipc/event_listener.cpp +++ b/server/src/ipc/event_listener.cpp @@ -5,6 +5,7 @@ #include #include #include +#include #include #include #include @@ -62,6 +63,13 @@ bool g_activate_uiless = false; bool g_session_uiless = false; } // namespace +namespace +{ +// 界面线程记下的鼠标操作所在页面代次,0 表示没有。不走任务队列:排在它前面的异步结果 +// 必须在合并前就看到它,而且鼠标事件不能打断翻页任务的合并。 +std::atomic g_candidate_ui_interaction_generation{0}; +} // namespace + namespace event_listener_detail { std::unordered_map g_candidate_translation_glosses; @@ -76,6 +84,7 @@ std::string g_date_time_page_keyword; std::vector g_translation_saved_items; int g_translation_saved_page_index = 0; int g_translation_saved_selected_index = 0; +bool g_translation_saved_selection_started = false; std::string TranslationIdentity(const EnglishIme::TranslationQuery &query) { @@ -88,6 +97,19 @@ bool IsCandidateSubPageActive() return g_translation_candidates_active || g_date_time_page_active; } +void LatchCandidateUiInteraction() +{ + const std::uint64_t generation = g_candidate_ui_interaction_generation.exchange(0, std::memory_order_acq_rel); + // 只认用户操作时屏幕上那一页;之后已经换了一批候选,就不该冻结新的这批。 + if (generation != 0 && generation == Global::LoadCandidatePageSnapshot()->generation) + Global::candidate_ui.begin_selection(); +} + +bool CanMergeAsyncCandidates() +{ + return !IsCandidateSubPageActive() && !Global::candidate_ui.selection_started; +} + bool IsUiLessMode() { return g_activate_uiless || g_session_uiless; @@ -656,6 +678,12 @@ std::wstring BuildCreateWordPipePayload(const std::string &remaining_raw_input_w namespace FanyNamedPipe { +void NoteCandidateUiInteraction(uint64_t page_generation) +{ + if (page_generation != 0) + g_candidate_ui_interaction_generation.store(page_generation, std::memory_order_release); +} + FanyImeTemporaryJapanese::Policy TemporaryJapanesePolicy() { // Keep the underlying Chinese scheme available to TSF's queued language-cycle projection. @@ -777,6 +805,7 @@ void ClearState() g_translation_saved_items.clear(); g_translation_saved_page_index = 0; g_translation_saved_selected_index = 0; + g_translation_saved_selection_started = false; EnglishIme::ClearTranslations(); CloudTranslation::Clear(); UpdateMixedInput({}); diff --git a/server/src/ipc/event_listener.h b/server/src/ipc/event_listener.h index 356e4895..c38f1ad4 100644 --- a/server/src/ipc/event_listener.h +++ b/server/src/ipc/event_listener.h @@ -61,6 +61,9 @@ void EnqueueCandidateTranslations(std::vector res bool merge = false); void EnqueueMixedCandidates(MixedCandidates::Result result, const std::string &input, uint64_t generation); void EnqueueCandidateUiAction(CandidateUiAction action, int one_based_index, int fixed_position = 0); +// 鼠标开始操作当前显示的候选页时,通知工作线程保持这批候选的顺序。界面线程调用,只记下页面 +// 代次,由工作线程在处理下一个任务前认领(见 LatchCandidateUiInteraction)。 +void NoteCandidateUiInteraction(uint64_t page_generation); // Paging carries a step count rather than a candidate index. Steps coalesce // into a page task already queued for the same client, so spinning the wheel // cannot outrun the worker with a long run of engine-backed page moves. diff --git a/server/src/ipc/event_listener_async_candidates.cpp b/server/src/ipc/event_listener_async_candidates.cpp index 10b79270..f2f15d02 100644 --- a/server/src/ipc/event_listener_async_candidates.cpp +++ b/server/src/ipc/event_listener_async_candidates.cpp @@ -32,7 +32,7 @@ void ApplyCloudCandidate(const std::string &candidate, const std::string &pinyin return; // A callback can become stale after enqueueing, while earlier key tasks run. // 译文页占用着同一份 items,异步候选必须等它退出再合并,否则会把译文冲掉。 - if (FindCloudRequestOrigin(pinyin, generation).client_id == 0 || !g_inputSession || IsCandidateSubPageActive()) + if (FindCloudRequestOrigin(pinyin, generation).client_id == 0 || !g_inputSession || !CanMergeAsyncCandidates()) return; if (candidate.empty()) @@ -97,7 +97,7 @@ void ApplyCloudCandidate(const std::string &candidate, const std::string &pinyin // 序有没有真的变:没变就一个字节都不动 UI。这既挡掉了过期结果,也挡掉了模型弃权的情况。 void ApplyRescoredOrder() { - if (!g_inputSession || IsCandidateSubPageActive()) + if (!g_inputSession || !CanMergeAsyncCandidates()) return; // 造词界面和译文页各自占着 items,重排不该去动它们。 if (GlobalIme::composition.creating_word.active) @@ -152,7 +152,7 @@ void ApplyAiCandidate(const std::string &candidate, const std::string &identity, const bool helpcode_active = has_session && g_inputSession->has_active_helpcode(); const std::string current_identity = has_session ? g_inputSession->get_pinyin_segmentation() : std::string{}; if (!enabled || candidate.empty() || !has_session || non_pinyin || !complete || helpcode_active || - GlobalIme::composition.creating_word.active || current_identity != identity || IsCandidateSubPageActive()) + GlobalIme::composition.creating_word.active || current_identity != identity || !CanMergeAsyncCandidates()) { (void)0; return; @@ -205,6 +205,10 @@ void ApplyEnglishCandidates(std::vector candidates, const std::string { return; } + // 等着这次答复时候选窗还没显示,用户只是在看不见的原文占位上按了方向键,不算开始选词: + // 照常换上英文候选,答复同时放行被扣下的显示。 + if (Global::candidate_ui.selection_started && !g_dedicated_english_answer_pending) + return; auto &items = Global::candidate_ui.items; std::string context_input = input; @@ -239,6 +243,7 @@ void ApplyEnglishCandidates(std::vector candidates, const std::string Global::candidate_ui.item_total_count = static_cast(items.size()); Global::candidate_ui.page_index = 0; Global::candidate_ui.select_first_on_page(); + Global::candidate_ui.selection_started = false; Global::candidate_ui.clear_page(); g_dedicated_english_answer_pending = false; RefreshCandidatePageUi(true); @@ -340,7 +345,7 @@ void InsertFirstAndAppendRest(std::vector &items, std::vectorcurrent_scheme_type() != SchemeType::Quanpin && g_inputSession->current_scheme_type() != SchemeType::Shuangpin) || g_inputSession->get_pinyin_sequence_with_cases() != input || GlobalIme::composition.creating_word.active) diff --git a/server/src/ipc/event_listener_candidates.cpp b/server/src/ipc/event_listener_candidates.cpp index 08385573..1e79420e 100644 --- a/server/src/ipc/event_listener_candidates.cpp +++ b/server/src/ipc/event_listener_candidates.cpp @@ -540,7 +540,8 @@ FanyImeIpc::EnglishPlacement CurrentEnglishPlacement(const std::vector // Pulls the next batch of candidates out of the session without disturbing // where the user currently is: set_items resets the page and the selection, so -// both are restored afterwards. Returns false when nothing more was loaded. +// both are restored afterwards. Returns false when nothing more was loaded, or +// when everything loaded was already on the list. bool ExpandCandidatesKeepingPagePosition() { auto &ui = Global::candidate_ui; @@ -556,8 +557,8 @@ bool ExpandCandidatesKeepingPagePosition() [](const std::string &key, const std::string &value) { return g_inputSession->find_candidate(key, value); }, g_inputSession->has_active_helpcode()); PlaceQuickPhrases(expanded, g_inputSession->get_pinyin_sequence_with_cases()); - FanyImeIpc::SetExpandedCandidatesKeepingPagePosition(ui, std::move(expanded), CurrentEnglishPlacement(ui.items)); - return true; + return FanyImeIpc::SetExpandedCandidatesKeepingPagePosition(ui, std::move(expanded), + CurrentEnglishPlacement(ui.items)); } // Moves one page in `offset`'s direction, expanding the candidate list first @@ -566,6 +567,7 @@ bool ExpandCandidatesKeepingPagePosition() PageMoveResult MoveCandidatePage(int offset) { auto &ui = Global::candidate_ui; + ui.begin_selection(); if (offset > 0 && ui.is_next_page_partial_last_page()) { // Populate the last partial page before entering it, so the first diff --git a/server/src/ipc/event_listener_internal.h b/server/src/ipc/event_listener_internal.h index 6f952a91..7f139b49 100644 --- a/server/src/ipc/event_listener_internal.h +++ b/server/src/ipc/event_listener_internal.h @@ -63,12 +63,18 @@ extern std::string g_date_time_page_keyword; extern std::vector g_translation_saved_items; extern int g_translation_saved_page_index; extern int g_translation_saved_selected_index; +extern bool g_translation_saved_selection_started; extern bool g_dedicated_english_answer_pending; extern int g_authoritative_cn_mode; std::string TranslationIdentity(const EnglishIme::TranslationQuery &query); // 译文页或日期页正占着候选框:items 不是这次输入的候选,异步结果不能往里合并,也不能按会话扩展。 bool IsCandidateSubPageActive(); +// 把界面线程记下的鼠标操作落到 candidate_ui.selection_started。工作线程每取一个任务先调一次, +// 这样排在鼠标操作之前入队的异步结果也看得到它。 +void LatchCandidateUiInteraction(); +// 异步候选(云、AI、英文、混输、重排)能否改动当前列表:子页占着或用户已开始选词时都不行。 +bool CanMergeAsyncCandidates(); bool IsUiLessMode(); void ApplyUiLessFromPacket(const FanyImeNamedpipeData &pipe_data); void RequestShowCandidateWindow(); diff --git a/server/src/ipc/event_listener_keys.cpp b/server/src/ipc/event_listener_keys.cpp index 74c75630..9653fc43 100644 --- a/server/src/ipc/event_listener_keys.cpp +++ b/server/src/ipc/event_listener_keys.cpp @@ -607,8 +607,10 @@ void ExitCandidateSubPage() g_translation_saved_items.clear(); ui.page_index = g_translation_saved_page_index; ui.selected_index_in_page = g_translation_saved_selected_index; + ui.selection_started = g_translation_saved_selection_started; g_translation_saved_page_index = 0; g_translation_saved_selected_index = 0; + g_translation_saved_selection_started = false; RefreshCandidatePageUi(false); } @@ -629,6 +631,7 @@ bool EnterDateTimeCandidatePage(const std::string &keyword) g_translation_saved_items = ui.items; g_translation_saved_page_index = ui.page_index; g_translation_saved_selected_index = ui.selected_index_in_page; + g_translation_saved_selection_started = ui.selection_started; ui.set_items(std::move(items)); g_date_time_page_active = true; g_date_time_page_keyword = keyword; @@ -699,6 +702,7 @@ void HandleTranslationCommitKey(uint64_t client_id, uint64_t activation_epoch, u g_translation_saved_items = ui.items; g_translation_saved_page_index = ui.page_index; g_translation_saved_selected_index = ui.selected_index_in_page; + g_translation_saved_selection_started = ui.selection_started; std::vector translation_items; translation_items.reserve(senses.size()); for (const auto &sense : senses) @@ -772,6 +776,7 @@ void PublishRestoredCompositionCandidates(uint64_t client_id, uint64_t activatio 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; + ui.begin_selection(); RefreshCandidatePageUi(false); } } @@ -1671,6 +1676,8 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques }; const auto move_selection = [&](int offset, UINT response_type) { result = response_type; + // 要在下面的扩展之前标记:扩展得按「已开始选词」只追加,不能重排已显示的候选。 + ui.begin_selection(); if (offset > 0 && (ui.is_selection_at_last_candidate() || (ui.is_selection_at_current_page_end() && ui.is_next_page_partial_last_page()))) { diff --git a/server/src/ipc/event_listener_worker.cpp b/server/src/ipc/event_listener_worker.cpp index ebb1570c..f002d481 100644 --- a/server/src/ipc/event_listener_worker.cpp +++ b/server/src/ipc/event_listener_worker.cpp @@ -218,6 +218,7 @@ void WorkerThread() if (task.type == TaskType::ApplyRescoredOrder) rescoreApplyQueued = false; } + LatchCandidateUiInteraction(); if (task.type == TaskType::ClientDeactivated || task.type == TaskType::ClientSuspended) { diff --git a/server/src/window/candidate_presenter.cpp b/server/src/window/candidate_presenter.cpp index 91c86911..ecc53e28 100644 --- a/server/src/window/candidate_presenter.cpp +++ b/server/src/window/candidate_presenter.cpp @@ -5,6 +5,7 @@ #include "defines/globals.h" #include "global/globals.h" #include "ipc/candidate_translation_policy.h" +#include "ipc/event_listener.h" #include "ipc/ipc.h" #include "log/candidate_diag_log.h" #include "skin/candidate_skin_catalog.h" @@ -1293,6 +1294,9 @@ void CandidatePresenter::ShowItemContextMenu(size_t pageIndex, POINT clientPoint // A mouse callback has no posted-message handoff at all, so it must not walk the worker's live page either. const Global::CandidatePageSnapshotPtr page = Global::LoadCandidatePageSnapshot(); + // 冻结的是用户右键时看见的那一页:已发布但还没画出来的一页不算。 + FanyNamedPipe::NoteCandidateUiInteraction( + Global::rendered_candidate_page_generation.load(std::memory_order_acquire)); std::wstring word; if (pageIndex < page->page_words.size()) { @@ -1987,6 +1991,28 @@ void CandidatePresenter::ArmHoverIfPointerMoved() } } +void CandidatePresenter::NoteHoverOverItem(POINT clientPoint) +{ + // 指针只是从候选窗上划过不算选词,要真的停到某个候选上才冻结这一页的顺序。 + const std::uint64_t generation = Global::rendered_candidate_page_generation.load(std::memory_order_acquire); + if (generation == hoverNotedGeneration_ || !impl_ || !impl_->list || !impl_->window) + { + return; + } + const msimeui::PointF point = impl_->window->ClientPixelsToDips(clientPoint); + for (size_t index = 0; impl_->list->GetItem(index) != nullptr; ++index) + { + const msimeui::RectF bounds = impl_->list->GetItemBounds(index); + if (point.x >= bounds.x && point.x < bounds.x + bounds.width && point.y >= bounds.y && + point.y < bounds.y + bounds.height) + { + FanyNamedPipe::NoteCandidateUiInteraction(generation); + hoverNotedGeneration_ = generation; + return; + } + } +} + bool CandidatePresenter::HandleMessage(UINT message, WPARAM wParam, LPARAM lParam) { if (!bound_ || !impl_ || !impl_->window) @@ -2032,6 +2058,10 @@ bool CandidatePresenter::HandleMessage(UINT message, WPARAM wParam, LPARAM lPara { return true; } + if (hoverArmed_) + { + NoteHoverOverItem(POINT{GET_X_LPARAM(lParam), GET_Y_LPARAM(lParam)}); + } impl_->window->DispatchImportedMessage(message, wParam, lParam); Present(); return true; diff --git a/server/src/window/candidate_presenter.h b/server/src/window/candidate_presenter.h index 94c35f47..b9bfdc9a 100644 --- a/server/src/window/candidate_presenter.h +++ b/server/src/window/candidate_presenter.h @@ -40,6 +40,7 @@ class CandidatePresenter // briefly showing the previous card. void PresentEmptyFrame(); void ArmHoverIfPointerMoved(); + void NoteHoverOverItem(POINT clientPoint); void CommitItem(size_t pageIndex); void ShowItemContextMenu(size_t pageIndex, POINT clientPoint); void CloseContextMenu(bool restoreHost); @@ -61,6 +62,8 @@ class CandidatePresenter // Set while PlaceAndShow resizes the host; it presents right afterwards. bool placingHost_ = false; POINT hoverBaseline_{}; + // 已为哪一页报过「指针停在候选上」,同一页只报一次。 + std::uint64_t hoverNotedGeneration_ = 0; float decorationTopDip_ = 0.0f; float decorationWidthDip_ = 0.0f; // 卡片最小宽度:默认 160,外部皮肤的 min_width_dip 与装饰图宽度可以把它撑大, diff --git a/server/tests/src/test_candidate_expansion_policy.cpp b/server/tests/src/test_candidate_expansion_policy.cpp index 0f0bcc7c..c97a4547 100644 --- a/server/tests/src/test_candidate_expansion_policy.cpp +++ b/server/tests/src/test_candidate_expansion_policy.cpp @@ -146,3 +146,63 @@ TEST_CASE(candidate_expansion_rebuilds_local_candidates_without_carrying_stale_f for (size_t index = 0; index < expanded.size(); ++index) REQUIRE_EQ(ui.items[index].word, expanded[index].word); } + +TEST_CASE(candidate_expansion_keeps_every_visible_position_after_selection_started) +{ + Global::CandidateUiState ui; + ui.page_size = 3; + auto initial = LocalCandidates(5); + initial.insert(initial.begin() + 1, WordItem("ni", "cloud", 0, CandidateSource::CloudSuggestion)); + ui.set_items(initial); + ui.page_index = 1; + ui.selected_index_in_page = 1; + ui.begin_selection(); + + // 新的引擎结果顺序已经变化,且带有重复的云候选;扩展只能在原列表后追加新词。 + auto expanded = LocalCandidates(10); + std::reverse(expanded.begin(), expanded.end()); + expanded.push_back(initial[1]); + REQUIRE(FanyImeIpc::SetExpandedCandidatesKeepingPagePosition(ui, std::move(expanded))); + REQUIRE(ui.selection_started); + REQUIRE_EQ(ui.page_index, 1); + REQUIRE_EQ(ui.selected_index_in_page, 1); + REQUIRE_EQ(ui.item_total_count, 11); + for (size_t index = 0; index < initial.size(); ++index) + { + REQUIRE_EQ(ui.items[index].word, initial[index].word); + REQUIRE(ui.items[index].source == initial[index].source); + } + REQUIRE_EQ(ui.items[6].word, std::string("local-9")); + REQUIRE_EQ(ui.items.back().word, std::string("local-5")); + REQUIRE(ui.has_next_page()); +} + +TEST_CASE(candidate_expansion_after_selection_keeps_same_word_with_other_pinyin_or_source) +{ + Global::CandidateUiState ui; + ui.page_size = 3; + ui.set_items({WordItem("hang", "行", 2), WordItem("ni", "cloud", 0, CandidateSource::CloudSuggestion)}); + ui.begin_selection(); + + // 多音字和快捷短语与已显示的同字,却是另外的候选;与界面独有的云候选同字的本地候选仍要挡掉。 + std::vector expanded{WordItem("hang", "行", 2), WordItem("xing", "行", 1), + WordItem("hang", "行", 0, CandidateSource::QuickPhrase), WordItem("ni", "cloud", 0)}; + REQUIRE(FanyImeIpc::SetExpandedCandidatesKeepingPagePosition(ui, std::move(expanded))); + REQUIRE_EQ(ui.item_total_count, 4); + REQUIRE_EQ(ui.items[2].pinyin, std::string("xing")); + REQUIRE(ui.items[3].source == CandidateSource::QuickPhrase); +} + +TEST_CASE(candidate_expansion_after_selection_reports_no_change_when_nothing_new) +{ + Global::CandidateUiState ui; + ui.page_size = 3; + ui.set_items(LocalCandidates(4)); + ui.begin_selection(); + + auto expanded = LocalCandidates(4); + std::reverse(expanded.begin(), expanded.end()); + REQUIRE(!FanyImeIpc::SetExpandedCandidatesKeepingPagePosition(ui, std::move(expanded))); + REQUIRE_EQ(ui.item_total_count, 4); + REQUIRE_EQ(ui.items.front().word, std::string("local-0")); +} diff --git a/server/tests/src/test_candidate_ui_state.cpp b/server/tests/src/test_candidate_ui_state.cpp index 031bbdf4..49ae8e91 100644 --- a/server/tests/src/test_candidate_ui_state.cpp +++ b/server/tests/src/test_candidate_ui_state.cpp @@ -48,6 +48,27 @@ TEST_CASE(candidate_ui_selection_stops_at_first_and_last_candidate) REQUIRE_EQ(state.selected_index_in_page, 1); } +TEST_CASE(candidate_ui_selection_stays_started_until_items_are_rebuilt) +{ + auto state = MakeCandidateUiState(8, 3); + REQUIRE(!state.selection_started); + REQUIRE(state.move_selection(1)); + REQUIRE(state.selection_started); + state.clear_page(); + state.select_first_on_page(); + REQUIRE(state.selection_started); + // 到达边界也已经表达了选词意图,异步结果不能趁这次按键没有移动而重排。 + REQUIRE(!state.move_selection(-1)); + REQUIRE(state.selection_started); + state.set_items({WordItem("hao", "好", 1)}); + REQUIRE(!state.selection_started); + state.begin_selection(); + REQUIRE(state.selection_started); + state.set_items({}); + state.begin_selection(); + REQUIRE(!state.selection_started); +} + TEST_CASE(candidate_ui_detects_only_the_last_loaded_candidate_as_exhausted) { auto state = MakeCandidateUiState(24, 6);