diff --git a/README.md b/README.md index 0623fbf0..97338b11 100644 --- a/README.md +++ b/README.md @@ -228,7 +228,7 @@ Windows 端的全部一方源码都在本仓,一次 clone 就能拿到完整 全拼 / 双拼输入中文时,可以把英文、emoji、颜文字、快捷短语和日期时间直接混进候选,不用切换到对应的快捷模式(五笔不混输)。这些开关集中在设置 → 实用功能的「候选混输」里:总开关关闭后全部不混入,展开后可逐项开关,各项的设置在总开关关闭期间保留。 - **中英混输**:根据字母串补充英文候选。触发字符数可选(1~8),达到该长度后才出现英文候选项。 -- **emoji 混输**:在英文候选之后插入匹配的 emoji;**颜文字混输**排在 emoji 之后。若同时开启云候选或 AI 联想,这些附加项会相应顺移。 +- **emoji 混输**:匹配的 emoji 排在候选项列表最后,不挤占正常汉字候选的位置;**颜文字混输**排在末尾的 emoji 之后。翻页扩展候选时仍保留在列表末尾。 - **快捷短语混入候选**:见下文「实用功能快捷模式」。 - **日期时间混入候选**(默认关闭):输入 `rq` / `riqi` / `date`、`sj` / `shijian` / `time`、`xq` / `xingqi` / `week` 时,当前日期、时间或星期出现在首个候选之后,其余格式排在列表末尾。双拼按当前方案换算后的整拼同样能触发,例如小鹤 `uijm` → `shijian`。 diff --git a/engine/core/candidate_queries.cpp b/engine/core/candidate_queries.cpp index b1e80447..80fa21f9 100644 --- a/engine/core/candidate_queries.cpp +++ b/engine/core/candidate_queries.cpp @@ -183,8 +183,6 @@ std::vector CandidateQueries::mixed(std::vector candidates, ++priority_slot; }; insert_leading(english_candidates); - insert_leading(emoji_candidates); - insert_leading(kaomoji_candidates); for (auto *source : {&english_candidates, &emoji_candidates, &kaomoji_candidates}) { diff --git a/engine/tests/src/test_mixed_expressive_input_session.cpp b/engine/tests/src/test_mixed_expressive_input_session.cpp index 8c59e69b..7cd8ea8b 100644 --- a/engine/tests/src/test_mixed_expressive_input_session.cpp +++ b/engine/tests/src/test_mixed_expressive_input_session.cpp @@ -1,5 +1,7 @@ #include "../../core/data_path.h" +#include "../../core/candidate_queries.h" #include "../../core/input_session.h" +#include "../../include/metasequoia/session.h" #include "../../user_dictionary/user_dictionary_journal.h" #include "test_directory_cleanup.h" @@ -161,21 +163,48 @@ int run_test() require(mixed.set_english_input_options(english_options), "Valid mixed-English options were rejected."); mixed.set_mixed_expressive_options(expressive_options); type(mixed, "ni"); - const std::vector expected_words{"你", "Ni", "😀", "(^_^)", "倪", "Ninja", "😁", "(T_T)"}; + const std::vector expected_words{"你", "Ni", "倪", "Ninja", "😀", "😁", "(^_^)", "(T_T)"}; require(mixed.candidates().size() == expected_words.size(), "Mixed candidate deduplication produced the wrong candidate count."); for (std::size_t index = 0; index < expected_words.size(); ++index) { require(mixed.candidates()[index].word == expected_words[index], - "Mixed candidates did not keep the Windows-compatible stable ordering."); + "Mixed expressive candidates did not stay after local Chinese and English candidates."); } - require(sources(mixed) == std::vector{CandidateSource::Database, - CandidateSource::EnglishDictionary, CandidateSource::Emoji, - CandidateSource::Kaomoji, CandidateSource::Database, - CandidateSource::EnglishDictionary, CandidateSource::Emoji, - CandidateSource::Kaomoji}, + require(sources(mixed) == + std::vector{CandidateSource::Database, CandidateSource::EnglishDictionary, + CandidateSource::Database, CandidateSource::EnglishDictionary, + CandidateSource::Emoji, CandidateSource::Emoji, CandidateSource::Kaomoji, + CandidateSource::Kaomoji}, "Mixed candidate sources did not retain their priority groups."); + metasequoia::SessionOptions public_options; + public_options.paths = metasequoia::RuntimePaths::legacy(); + public_options.english = english_options; + public_options.expressive = expressive_options; + public_options.helpcode = false; + metasequoia::Session public_session(public_options); + require(public_session.character('n').handled && public_session.character('i').handled, + "The public Session facade did not handle mixed-expression input."); + const auto public_candidates = public_session.snapshot().candidates; + require(public_candidates.size() == expected_words.size(), "The public Session lost mixed candidates."); + for (std::size_t index = 0; index < expected_words.size(); ++index) + require(public_candidates[index].word == expected_words[index], + "The public Session facade did not keep expressive candidates at the end."); + + // Cloud/AI slots affect English only; all expressive candidates still follow the local list. + metasequoia::CandidateQueries queries(metasequoia::RuntimePaths::legacy(), GetXiaoheShuangpinProfile()); + const auto with_online = queries.mixed( + {WordItem("ni", "你", 200), WordItem("ni", "cloud", 0, CandidateSource::CloudSuggestion), + WordItem("ni", "ai", 0, CandidateSource::AiSuggestion), WordItem("ni", "倪", 100)}, + "ni", SchemeType::Quanpin, english_options, expressive_options, false, metasequoia::LocalInputMode::None); + const std::vector expected_online{"你", "cloud", "ai", "Ni", "倪", + "Ninja", "😀", "😁", "(^_^)", "(T_T)"}; + require(with_online.size() == expected_online.size(), "Online candidates changed mixed deduplication."); + for (std::size_t index = 0; index < expected_online.size(); ++index) + require(with_online[index].word == expected_online[index], + "Cloud or AI candidates moved expressive candidates ahead of local candidates."); + metasequoia::InputSession below_threshold(SchemeType::Quanpin); require(below_threshold.set_english_input_options(english_options), "Valid mixed-English options were rejected below the expressive threshold."); @@ -194,6 +223,11 @@ int run_test() !has_source(emoji_only, CandidateSource::EnglishDictionary) && !has_source(emoji_only, CandidateSource::Kaomoji), "The mixed Emoji toggle was not independent."); + require(emoji_only.candidates().size() >= 3, "Mixed Emoji candidates were incomplete."); + require(emoji_only.candidates()[0].word == "你" && emoji_only.candidates()[1].word == "倪" && + emoji_only.candidates()[2].source == CandidateSource::Emoji && + emoji_only.candidates().back().source == CandidateSource::Emoji, + "Mixed Emoji candidates displaced Chinese candidates with mixed English disabled."); metasequoia::MixedExpressiveOptions kaomoji_only_options; kaomoji_only_options.kaomoji_candidates = true; @@ -204,6 +238,11 @@ int run_test() !has_source(kaomoji_only, CandidateSource::EnglishDictionary) && !has_source(kaomoji_only, CandidateSource::Emoji), "The mixed kaomoji toggle was not independent."); + require(kaomoji_only.candidates().size() >= 3, "Mixed kaomoji candidates were incomplete."); + require(kaomoji_only.candidates()[0].word == "你" && kaomoji_only.candidates()[1].word == "倪" && + kaomoji_only.candidates()[2].source == CandidateSource::Kaomoji && + kaomoji_only.candidates().back().source == CandidateSource::Kaomoji, + "Mixed kaomoji candidates displaced Chinese candidates with mixed English disabled."); const std::filesystem::path missing_emoji_table = root / "missing-emoji-table"; prepare_main_database(missing_emoji_table); diff --git a/server/src/ipc/candidate_expansion_policy.h b/server/src/ipc/candidate_expansion_policy.h new file mode 100644 index 00000000..3e79eccf --- /dev/null +++ b/server/src/ipc/candidate_expansion_policy.h @@ -0,0 +1,43 @@ +#pragma once + +#include "global/globals.h" +#include "ipc/candidate_selection_policy.h" + +#include +#include +#include + +namespace FanyImeIpc +{ +// 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, + const EnglishPlacement &english_placement = {}) +{ + 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; + } + } + NormalizeMixedCandidateOrder(expanded, 1, english_placement); + const int current_page = ui.page_index; + const int current_selection = ui.selected_index_in_page; + ui.set_items(std::move(expanded)); + ui.page_index = current_page; + ui.selected_index_in_page = current_selection; +} +} // namespace FanyImeIpc diff --git a/server/src/ipc/event_listener_async_candidates.cpp b/server/src/ipc/event_listener_async_candidates.cpp index 70170d43..bfa562ae 100644 --- a/server/src/ipc/event_listener_async_candidates.cpp +++ b/server/src/ipc/event_listener_async_candidates.cpp @@ -313,6 +313,12 @@ std::vector ReplaceSourceAndDeduplicate(std::vector &items, return unique_candidates; } +// Merge query results; NormalizeMixedCandidateOrder determines their final positions. +void AppendCandidates(std::vector &items, std::vector candidates) +{ + items.insert(items.end(), std::make_move_iterator(candidates.begin()), std::make_move_iterator(candidates.end())); +} + // 首个插到 insert_index(最终位置由 NormalizeMixedCandidateOrder 按来源统一摆),其余追加到末尾。 void InsertFirstAndAppendRest(std::vector &items, std::vector candidates, size_t insert_index) { @@ -379,16 +385,14 @@ void ApplyMixedCandidates(MixedCandidates::Result result, const std::string &inp { auto emoji = ReplaceSourceAndDeduplicate(items, CandidateSource::Emoji, std::move(result.emoji)); changed = changed || !emoji.empty(); - for (auto &candidate : emoji) - items.push_back(std::move(candidate)); + AppendCandidates(items, std::move(emoji)); } if (GetConfiguredKaomojiMixedInputEnabled()) { auto kaomoji = ReplaceSourceAndDeduplicate(items, CandidateSource::Kaomoji, std::move(result.kaomoji)); changed = changed || !kaomoji.empty(); - for (auto &candidate : kaomoji) - items.push_back(std::move(candidate)); + AppendCandidates(items, std::move(kaomoji)); } // 全被去重掉、列表也没少东西时,屏幕上的那页就是对的,不必重画。 diff --git a/server/src/ipc/event_listener_candidates.cpp b/server/src/ipc/event_listener_candidates.cpp index f0e23220..f5ff937a 100644 --- a/server/src/ipc/event_listener_candidates.cpp +++ b/server/src/ipc/event_listener_candidates.cpp @@ -9,6 +9,7 @@ #include #include #include "ipc.h" +#include "ipc/candidate_expansion_policy.h" #include "ipc/candidate_ui_owner.h" #include "ipc/input_key_policy.h" #include "utils/common_utils.h" @@ -528,17 +529,13 @@ bool ExpandCandidatesKeepingPagePosition() { return false; } - const int current_page = ui.page_index; - const int current_selection = ui.selected_index_in_page; auto expanded = g_inputSession->get_candidates(); user_dictionary::apply_fixed_positions( user_dictionary::default_user_db_path(), CurrentRankingContextKey(), expanded, true, [](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()); - ui.set_items(std::move(expanded)); - ui.page_index = current_page; - ui.selected_index_in_page = current_selection; + FanyImeIpc::SetExpandedCandidatesKeepingPagePosition(ui, std::move(expanded), CurrentEnglishPlacement(ui.items)); return true; } diff --git a/server/tests/CMakeLists.txt b/server/tests/CMakeLists.txt index 860e415a..ff4bfc16 100644 --- a/server/tests/CMakeLists.txt +++ b/server/tests/CMakeLists.txt @@ -44,6 +44,7 @@ set(TEST_SOURCE_FILES "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_ui_action_policy.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_translation_policy.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_ui_state.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_expansion_policy.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_composition_selection_history.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_render_sync.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_selection_replay_guard.cpp" diff --git a/server/tests/src/test_candidate_expansion_policy.cpp b/server/tests/src/test_candidate_expansion_policy.cpp new file mode 100644 index 00000000..431239a6 --- /dev/null +++ b/server/tests/src/test_candidate_expansion_policy.cpp @@ -0,0 +1,143 @@ +#include "ipc/candidate_expansion_policy.h" +#include "tests/includes/test_framework.h" + +#include +#include +#include + +namespace +{ +std::vector LocalCandidates(int count) +{ + std::vector items; + for (int index = 0; index < count; ++index) + items.emplace_back("ni", "local-" + std::to_string(index), count - index); + return items; +} + +std::vector MixedCandidates() +{ + return {WordItem("ni", "Ni", 100, CandidateSource::EnglishDictionary), + WordItem("ninja", "Ninja", 50, CandidateSource::EnglishDictionary), + WordItem("ni", "😀", 0, CandidateSource::Emoji), + WordItem("ni", "😁", 0, CandidateSource::Emoji), + WordItem("ni", "(^_^)", 0, CandidateSource::Kaomoji), + WordItem("ni", "(T_T)", 0, CandidateSource::Kaomoji), + WordItem("date:ymd_dash", "2026-10-08", 0, CandidateSource::DateTime), + metasequoia::local_modes::date_time_menu_item("rq"), + WordItem("ni", "cloud", 0, CandidateSource::CloudSuggestion), + WordItem("ni", "ai", 0, CandidateSource::AiSuggestion)}; +} + +void RequireExpressiveTail(const std::vector &items) +{ + REQUIRE(items.size() >= 4); + const size_t start = items.size() - 4; + REQUIRE_EQ(items[start].word, std::string("😀")); + REQUIRE_EQ(items[start + 1].word, std::string("😁")); + REQUIRE_EQ(items[start + 2].word, std::string("(^_^)")); + REQUIRE_EQ(items[start + 3].word, std::string("(T_T)")); +} +} // namespace + +TEST_CASE(candidate_expansion_preserves_mixed_results_before_entering_a_partial_last_page) +{ + Global::CandidateUiState ui; + ui.page_size = 8; + auto initial = LocalCandidates(24); + const auto mixed = MixedCandidates(); + initial.insert(initial.end(), mixed.begin(), mixed.end()); + FanyImeIpc::NormalizeMixedCandidateOrder(initial); + ui.set_items(std::move(initial)); + ui.page_index = 3; + ui.selected_index_in_page = 2; + REQUIRE(ui.is_next_page_partial_last_page()); + + // This is the replacement step used by ExpandCandidatesKeepingPagePosition after the + // session has loaded its next batch. The old set_items(expanded) loses every mixed result. + FanyImeIpc::SetExpandedCandidatesKeepingPagePosition(ui, LocalCandidates(60)); + REQUIRE_EQ(ui.page_index, 3); + REQUIRE_EQ(ui.selected_index_in_page, 2); + REQUIRE_EQ(ui.item_total_count, 70); + REQUIRE_EQ(ui.items[0].word, std::string("local-0")); + REQUIRE_EQ(ui.items[1].source, CandidateSource::DateTime); + REQUIRE(metasequoia::local_modes::is_date_time_menu_item(ui.items[2])); + REQUIRE_EQ(ui.items[3].source, CandidateSource::CloudSuggestion); + REQUIRE_EQ(ui.items[4].source, CandidateSource::AiSuggestion); + REQUIRE_EQ(ui.items[5].source, CandidateSource::EnglishDictionary); + RequireExpressiveTail(ui.items); + + // The final page remains reachable and still contains Emoji followed by kaomoji. + while (ui.has_next_page()) + ++ui.page_index; + REQUIRE_EQ(ui.page_index, 8); + REQUIRE_EQ(ui.current_page_count(), 6); + REQUIRE_EQ(ui.items[ui.item_total_count - 1].source, CandidateSource::Kaomoji); +} + +TEST_CASE(candidate_expansion_refills_the_current_last_page_and_keeps_learned_english_placement) +{ + Global::CandidateUiState ui; + ui.page_size = 8; + auto initial = LocalCandidates(24); + const auto mixed = MixedCandidates(); + initial.insert(initial.end(), mixed.begin(), mixed.end()); + ui.set_items(std::move(initial)); + ui.page_index = 4; + ui.selected_index_in_page = 1; + REQUIRE(!ui.has_next_page()); + REQUIRE(!ui.is_current_page_full()); + FanyImeIpc::EnglishPlacement placement; + placement.slot = 6; + placement.input = "ni"; + placement.page_size = 8; + + FanyImeIpc::SetExpandedCandidatesKeepingPagePosition(ui, LocalCandidates(60), placement); + REQUIRE_EQ(ui.page_index, 4); + REQUIRE_EQ(ui.selected_index_in_page, 1); + REQUIRE(ui.is_current_page_full()); + const auto english_index = FanyImeIpc::SlottedEnglishIndex(ui.items); + REQUIRE(english_index.has_value()); + REQUIRE_EQ(*english_index, size_t{6}); + RequireExpressiveTail(ui.items); +} + +TEST_CASE(candidate_expansion_deduplicates_results_and_preserves_fixed_english_metadata) +{ + Global::CandidateUiState ui; + auto initial = LocalCandidates(24); + auto mixed = MixedCandidates(); + mixed.front().fixed_position = 1; + mixed.front().corrected_from = "metadata"; + initial.insert(initial.end(), mixed.begin(), mixed.end()); + ui.set_items(std::move(initial)); + + auto expanded = LocalCandidates(60); + expanded.push_back(mixed[1]); + expanded.emplace_back("ni", "cloud", 1); + expanded.emplace_back("ni", "local-quick-phrase", 0, CandidateSource::QuickPhrase); + FanyImeIpc::SetExpandedCandidatesKeepingPagePosition(ui, expanded); + REQUIRE_EQ(ui.item_total_count, 71); + REQUIRE_EQ(ui.items[0].word, std::string("Ni")); + REQUIRE_EQ(ui.items[0].fixed_position, 1); + REQUIRE_EQ(ui.items[0].corrected_from, std::string("metadata")); + REQUIRE_EQ( + std::count_if(ui.items.begin(), ui.items.end(), [](const WordItem &item) { return item.word == "cloud"; }), 1); + RequireExpressiveTail(ui.items); + + FanyImeIpc::SetExpandedCandidatesKeepingPagePosition(ui, std::move(expanded)); + REQUIRE_EQ(ui.item_total_count, 71); + RequireExpressiveTail(ui.items); +} + +TEST_CASE(candidate_expansion_rebuilds_local_candidates_without_carrying_stale_fallbacks_or_phrases) +{ + Global::CandidateUiState ui; + ui.set_items({WordItem("ni", "old-local", 1), WordItem("ni", "old-fallback", 0, CandidateSource::Fallback), + WordItem("ni", "old-phrase", 0, CandidateSource::QuickPhrase)}); + auto expanded = LocalCandidates(60); + FanyImeIpc::SetExpandedCandidatesKeepingPagePosition(ui, expanded); + REQUIRE_EQ(ui.item_total_count, 60); + for (size_t index = 0; index < expanded.size(); ++index) + REQUIRE_EQ(ui.items[index].word, expanded[index].word); +} diff --git a/server/tests/src/test_engine_shuangpin_session.cpp b/server/tests/src/test_engine_shuangpin_session.cpp index 9baef417..204f3ff6 100644 --- a/server/tests/src/test_engine_shuangpin_session.cpp +++ b/server/tests/src/test_engine_shuangpin_session.cpp @@ -429,7 +429,7 @@ TEST_CASE(FixedEnglishCandidateKeepsItsMixedCandidatePosition) REQUIRE_EQ(items[2].word, std::string("GitHub")); } -TEST_CASE(EmojiMixedCandidateFollowsEnglishAndShiftsWithCloudAndAi) +TEST_CASE(EmojiMixedCandidatesStayAtTheEndWithCloudAndAi) { const auto local = [](std::string word) { return WordItem("ni", std::move(word), 100); }; const auto english = [] { return WordItem("ni", "nice", 1, CandidateSource::EnglishDictionary); }; @@ -470,7 +470,7 @@ TEST_CASE(EmojiMixedCandidateFollowsEnglishAndShiftsWithCloudAndAi) REQUIRE_EQ(items[4].source, CandidateSource::Emoji); } -TEST_CASE(KaomojiMixedCandidateSitsRightAfterEmoji) +TEST_CASE(KaomojiMixedCandidatesFollowEmojiAtTheEnd) { const auto local = [](std::string word) { return WordItem("ni", std::move(word), 100); }; const auto english = [] { return WordItem("ni", "nice", 1, CandidateSource::EnglishDictionary); }; diff --git a/ui-html/webview2/settings/ime-settings/src/partials/tools-settings.html b/ui-html/webview2/settings/ime-settings/src/partials/tools-settings.html index d490bdd7..a38859bb 100644 --- a/ui-html/webview2/settings/ime-settings/src/partials/tools-settings.html +++ b/ui-html/webview2/settings/ime-settings/src/partials/tools-settings.html @@ -60,7 +60,7 @@
emoji 混输
-
在候选项中加入匹配的 emoji(位于英文候选之后;云候选与 AI 联想会使其相应顺移)
+
在候选项中加入匹配的 emoji(排在候选项列表最后,不挤占正常汉字候选的位置)
@@ -69,7 +69,7 @@
颜文字混输
-
在候选项中加入匹配的颜文字(排在 emoji 之后)
+
在候选项中加入匹配的颜文字(排在列表末尾的 emoji 之后)