fix(server): 选词期间保持候选顺序和高亮位置 (#1022)

* fix(server): 选词期间保持候选顺序和高亮位置

* fix(server): 收紧选词期间冻结候选顺序的触发与边界

- 鼠标操作改为界面线程记下页面代次、工作线程出队时认领,不再入队:排在悬停之前的异步结果也会被挡住,滚轮翻页任务的合并不再被打断
- 悬停只在指针停到某个候选上才算开始选词;右键按已画出的那一页认定
- 专用英文在候选窗显示前的方向键不算选词,答复照常换上候选并放行被扣下的显示
- 退出译文/日期子页恢复进入前的选词状态,而不是一律标记为已开始
- 选词后的翻页扩展按「词 + 拼音 + 来源」抵掉已显示的候选,没有新增时报告未变化
- 五处异步合并共用 CanMergeAsyncCandidates

---------

Co-authored-by: fanlusky <1730976608@qq.com>
This commit is contained in:
古明四季andfanlusky authored and GitHub committed 2026-10-17 19:27:52 +08:00
1 parent 96b64d20bd
commit 26354ce0f4
13 files changed
+246 -23

No files matched your search

+9
View File
@@ -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<CandidateWordItem> new_items)
{
@@ -311,6 +313,7 @@ struct CandidateUiState
item_total_count = static_cast<int>(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)
+63 -16
View File
@@ -4,34 +4,80 @@
#include "ipc/candidate_selection_policy.h"
#include <algorithm>
#include <string>
#include <unordered_map>
#include <unordered_set>
#include <utility>
#include <vector>
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<WordItem> expanded,
// Returns false when the list did not change.
inline bool SetExpandedCandidatesKeepingPagePosition(Global::CandidateUiState &ui, std::vector<WordItem> 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<int>(item.source));
};
std::unordered_map<std::string, int> shown;
std::unordered_set<std::string> 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<int>(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
+29
View File
@@ -5,6 +5,7 @@
#include <ioapiset.h>
#include <string>
#include <algorithm>
#include <atomic>
#include <cstdint>
#include <utility>
#include <unordered_map>
@@ -62,6 +63,13 @@ bool g_activate_uiless = false;
bool g_session_uiless = false;
} // namespace
namespace
{
// 界面线程记下的鼠标操作所在页面代次,0 表示没有。不走任务队列:排在它前面的异步结果
// 必须在合并前就看到它,而且鼠标事件不能打断翻页任务的合并。
std::atomic<std::uint64_t> g_candidate_ui_interaction_generation{0};
} // namespace
namespace event_listener_detail
{
std::unordered_map<std::string, std::string> g_candidate_translation_glosses;
@@ -76,6 +84,7 @@ std::string g_date_time_page_keyword;
std::vector<WordItem> 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({});
+3
View File
@@ -61,6 +61,9 @@ void EnqueueCandidateTranslations(std::vector<EnglishIme::TranslationResult> 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.
@@ -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<WordItem> 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<WordItem> candidates, const std::string
Global::candidate_ui.item_total_count = static_cast<int>(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<WordItem> &items, std::vector<WordItem
void ApplyMixedCandidates(MixedCandidates::Result result, const std::string &input, uint64_t generation)
{
if (!GetConfiguredMixedCandidatesEnabled() || !MixedCandidates::IsCurrent(input, generation) ||
g_inputSession == nullptr || g_english_input_mode || IsCandidateSubPageActive() ||
g_inputSession == nullptr || g_english_input_mode || !CanMergeAsyncCandidates() ||
(g_inputSession->current_scheme_type() != SchemeType::Quanpin &&
g_inputSession->current_scheme_type() != SchemeType::Shuangpin) ||
g_inputSession->get_pinyin_sequence_with_cases() != input || GlobalIme::composition.creating_word.active)
+5 -3
View File
@@ -540,7 +540,8 @@ FanyImeIpc::EnglishPlacement CurrentEnglishPlacement(const std::vector<WordItem>
// 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
+6
View File
@@ -63,12 +63,18 @@ extern std::string g_date_time_page_keyword;
extern std::vector<WordItem> 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();
+7
View File
@@ -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<WordItem> 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())))
{
+1
View File
@@ -218,6 +218,7 @@ void WorkerThread()
if (task.type == TaskType::ApplyRescoredOrder)
rescoreApplyQueued = false;
}
LatchCandidateUiInteraction();
if (task.type == TaskType::ClientDeactivated || task.type == TaskType::ClientSuspended)
{
+30
View File
@@ -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;
+3
View File
@@ -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 与装饰图宽度可以把它撑大,
@@ -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<WordItem> 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"));
}
@@ -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);