fix(server): 翻页扩展时保留混输候选并同步 Emoji 末尾排序

This commit is contained in:
normaltank committed 2026-10-08 22:10:13 +08:00
1 parent 6489d4be17
commit c2bd59d448
10 files changed
+248 -23

No files matched your search

+1 -1
View File
@@ -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`。
-2
View File
@@ -183,8 +183,6 @@ std::vector<WordItem> CandidateQueries::mixed(std::vector<WordItem> candidates,
++priority_slot;
};
insert_leading(english_candidates);
insert_leading(emoji_candidates);
insert_leading(kaomoji_candidates);
for (auto *source : {&english_candidates, &emoji_candidates, &kaomoji_candidates})
{
@@ -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<std::string> expected_words{"你", "Ni", "😀", "(^_^)", "倪", "Ninja", "😁", "(T_T)"};
const std::vector<std::string> 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>{CandidateSource::Database,
CandidateSource::EnglishDictionary, CandidateSource::Emoji,
CandidateSource::Kaomoji, CandidateSource::Database,
CandidateSource::EnglishDictionary, CandidateSource::Emoji,
CandidateSource::Kaomoji},
require(sources(mixed) ==
std::vector<CandidateSource>{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<std::string> 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);
@@ -0,0 +1,43 @@
#pragma once
#include "global/globals.h"
#include "ipc/candidate_selection_policy.h"
#include <algorithm>
#include <utility>
#include <vector>
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<WordItem> 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
@@ -313,6 +313,12 @@ std::vector<WordItem> ReplaceSourceAndDeduplicate(std::vector<WordItem> &items,
return unique_candidates;
}
// Merge query results; NormalizeMixedCandidateOrder determines their final positions.
void AppendCandidates(std::vector<WordItem> &items, std::vector<WordItem> candidates)
{
items.insert(items.end(), std::make_move_iterator(candidates.begin()), std::make_move_iterator(candidates.end()));
}
// 首个插到 insert_index(最终位置由 NormalizeMixedCandidateOrder 按来源统一摆),其余追加到末尾。
void InsertFirstAndAppendRest(std::vector<WordItem> &items, std::vector<WordItem> 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));
}
// 全被去重掉、列表也没少东西时,屏幕上的那页就是对的,不必重画。
+2 -5
View File
@@ -9,6 +9,7 @@
#include <cstdint>
#include <utility>
#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;
}
+1
View File
@@ -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"
@@ -0,0 +1,143 @@
#include "ipc/candidate_expansion_policy.h"
#include "tests/includes/test_framework.h"
#include <algorithm>
#include <string>
#include <vector>
namespace
{
std::vector<WordItem> LocalCandidates(int count)
{
std::vector<WordItem> items;
for (int index = 0; index < count; ++index)
items.emplace_back("ni", "local-" + std::to_string(index), count - index);
return items;
}
std::vector<WordItem> 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<WordItem> &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);
}
@@ -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); };
@@ -60,7 +60,7 @@
<div class="section-header">
<div>
<div class="section-title">emoji 混输</div>
<div class="input-setting-description">在候选项中加入匹配的 emoji(位于英文候选之后;云候选与 AI 联想会使其相应顺移)</div>
<div class="input-setting-description">在候选项中加入匹配的 emoji(排在候选项列表最后,不挤占正常汉字候选的位置)</div>
</div>
<div class="ftb-toggle-btn active" id="emojiMixedInputToggleBtn" role="switch" aria-checked="true" tabindex="0">
<div class="ftb-toggle-knob"></div>
@@ -69,7 +69,7 @@
<div class="section-header">
<div>
<div class="section-title">颜文字混输</div>
<div class="input-setting-description">在候选项中加入匹配的颜文字(排在 emoji 之后)</div>
<div class="input-setting-description">在候选项中加入匹配的颜文字(排在列表末尾的 emoji 之后)</div>
</div>
<div class="ftb-toggle-btn active" id="kaomojiMixedInputToggleBtn" role="switch" aria-checked="true" tabindex="0">
<div class="ftb-toggle-knob"></div>