From a898c3b11cb8f09deae6615891e4f6b2c21f8c42 Mon Sep 17 00:00:00 2001 From: fanlusky <1730976608@qq.com> Date: Thu, 17 Sep 2026 17:34:18 +0800 Subject: [PATCH] =?UTF-8?q?feat(candidate):=20Ctrl+Enter=20=E4=B8=8A?= =?UTF-8?q?=E5=B1=8F=E5=80=99=E9=80=89=E5=8F=B3=E4=BE=A7=E7=9A=84=E8=AF=91?= =?UTF-8?q?=E6=96=87?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 候选框开着且高亮候选带译文时,Ctrl+Enter 上屏那条译文而不是候选本身。 译文只有一条就直接上屏;有多条时把候选框整个换成这几条译义,空格/数字键 照常选一条上屏。 TSF 端原先在分类器最前面把所有 Ctrl 组合都还给应用,Ctrl+Enter 到不了 Server。现在在那条拦截之前先判一次:候选框开着才认,否则组合仍归应用。 认下来的键走既有的 FUNCTION_SERVER_CANDIDATE_KEY 通道,由 Server 决定 是上屏(CommitExactText)还是只开一个新候选页(NavigationIgnored)。 译文页复用同一份 candidate_ui.items,因此云/AI/英文/emoji/颜文字这些异步 回调和候选扩展都要在这个子模式下让路,否则会把译文冲掉。输入串全程没动, 退出时把存下来的 items 和页码原样放回去即可。译文页上的空格/数字键只回 Normal / OutofRange——SELECT_BY_NUMBER 的 TSF 路径只认这两种。 --- server/CMakeLists.txt | 1 + server/src/ipc/candidate_translation_policy.h | 53 ++++++ server/src/ipc/event_listener.cpp | 172 ++++++++++++++++-- server/tests/CMakeLists.txt | 1 + .../src/test_candidate_translation_policy.cpp | 52 ++++++ windows/src/Key/KeyEventSink.cpp | 32 ++++ 6 files changed, 299 insertions(+), 12 deletions(-) create mode 100644 server/src/ipc/candidate_translation_policy.h create mode 100644 server/tests/src/test_candidate_translation_policy.cpp diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index 779d911f..cbe8de89 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -171,6 +171,7 @@ set(SRC_FILES "./src/english/english_ime.cpp" "./src/english/english_ime.h" "./src/global/globals.h" + "./src/ipc/candidate_translation_policy.h" "./src/ipc/event_listener.cpp" "./src/ipc/event_listener.h" "./src/ipc/ipc.cpp" diff --git a/server/src/ipc/candidate_translation_policy.h b/server/src/ipc/candidate_translation_policy.h new file mode 100644 index 00000000..c46596ef --- /dev/null +++ b/server/src/ipc/candidate_translation_policy.h @@ -0,0 +1,53 @@ +#pragma once + +#include +#include +#include +#include + +#include "input_key_policy.h" + +namespace FanyImeIpc +{ +inline constexpr uint32_t kVirtualKeyReturn = 0x0D; + +// Ctrl+Enter commits the translation shown to the right of the highlighted +// candidate instead of the candidate itself. Shift or Alt must not be down: +// those combinations still belong to the application. +constexpr bool IsTranslationCommitKey(uint32_t keycode, uint32_t modifiers_down) +{ + return keycode == kVirtualKeyReturn && (modifiers_down & kKeyModifierMask) == kModifierControl; +} + +// english.db stores one gloss string per entry with the senses already joined: +// en->zh uses the fullwidth ';' ("苹果;家伙"), zh->en the ASCII "; " ("string; trail"). +// A cloud gloss is a single sentence and has no separator, so it stays one entry. +// Header-only pure policy so tests can pin it without linking the server stack. +inline std::vector SplitTranslationGloss(std::string_view gloss) +{ + static constexpr std::string_view kFullwidthSemicolon = "\xEF\xBC\x9B"; + std::vector senses; + size_t start = 0; + const auto push = [&senses](std::string_view sense) { + const size_t first = sense.find_first_not_of(" \t\r\n"); + if (first == std::string_view::npos) + return; + const size_t last = sense.find_last_not_of(" \t\r\n"); + senses.emplace_back(sense.substr(first, last - first + 1)); + }; + while (start <= gloss.size()) + { + const size_t ascii = gloss.find(';', start); + const size_t fullwidth = gloss.find(kFullwidthSemicolon, start); + const size_t cut = (std::min)(ascii, fullwidth); + if (cut == std::string_view::npos) + { + push(gloss.substr(start)); + break; + } + push(gloss.substr(start, cut - start)); + start = cut + (cut == fullwidth ? kFullwidthSemicolon.size() : 1); + } + return senses; +} +} // namespace FanyImeIpc diff --git a/server/src/ipc/event_listener.cpp b/server/src/ipc/event_listener.cpp index c6ad6705..7ce64074 100644 --- a/server/src/ipc/event_listener.cpp +++ b/server/src/ipc/event_listener.cpp @@ -19,6 +19,7 @@ #include "ipc/async_request_origin.h" #include "ipc/candidate_ui_owner.h" #include "ipc/candidate_text_policy.h" +#include "ipc/candidate_translation_policy.h" #include "ipc/focus_session_policy.h" #include "ipc/input_key_policy.h" #include "engine/contracts/ipc_negotiation.h" @@ -100,6 +101,14 @@ bool g_session_uiless = false; std::unordered_map g_candidate_translation_glosses; std::string g_candidate_translation_signature; +// 副候选框:Ctrl+Enter 在高亮候选有多条译义时,把候选框整个换成那几条译义,让空格/ +// 数字键像选普通候选一样选一条上屏。输入串一个字都没动,所以退出这个子模式时把原来的 +// items / 页码 / 高亮位原样放回去就行,不需要重新查词。 +bool g_translation_candidates_active = false; +std::vector g_translation_saved_items; +int g_translation_saved_page_index = 0; +int g_translation_saved_selected_index = 0; + std::shared_ptr PersistentInputSession() { return g_r_mode_original_session ? g_r_mode_original_session : g_inputSession; @@ -970,10 +979,14 @@ std::string BuildCurrentCandidatePage() ui.clear_page(); const SchemeType current_scheme = g_inputSession->current_scheme_type(); const bool uppercase_all_helpcodes = current_scheme == SchemeType::Quanpin; - const bool show_helpcodes = (current_scheme == SchemeType::Shuangpin && GetConfiguredShuangpinHelpcodeEnabled() && - GetConfiguredShowShuangpinHelpcodeInCandidateWindow()) || - (current_scheme == SchemeType::Quanpin && GetConfiguredQuanpinHelpcodeEnabled() && - GetConfiguredShowQuanpinHelpcodeInCandidateWindow()); + // 副候选框里装的是译文,不是这次输入的候选:助记码、云/AI 角标和「右侧译文」都不适用, + // 而且 g_candidate_translation_glosses 还留着原候选的译文,照常查会把译文再标注一遍。 + const bool translation_page = g_translation_candidates_active; + const bool show_helpcodes = + !translation_page && ((current_scheme == SchemeType::Shuangpin && GetConfiguredShuangpinHelpcodeEnabled() && + GetConfiguredShowShuangpinHelpcodeInCandidateWindow()) || + (current_scheme == SchemeType::Quanpin && GetConfiguredQuanpinHelpcodeEnabled() && + GetConfiguredShowQuanpinHelpcodeInCandidateWindow())); const int start = ui.current_page_start(); const int loop = ui.current_page_count(); @@ -1015,7 +1028,7 @@ std::string BuildCurrentCandidatePage() view.badge = " 🤖"; view.fixed_position = item.fixed_position > 0; EnglishIme::TranslationQuery translation_query; - if (BuildTranslationQuery(item, translation_query)) + if (!translation_page && BuildTranslationQuery(item, translation_query)) { const auto gloss = g_candidate_translation_glosses.find(TranslationIdentity(translation_query)); if (gloss != g_candidate_translation_glosses.end()) @@ -1052,6 +1065,11 @@ void PrepareCandidateTranslationRequest() const bool japanese = g_inputSession && g_inputSession->current_scheme_type() == SchemeType::JapaneseRomaji; const bool enabled = GetConfiguredCandidateTranslationsEnabled() && !IsUiLessMode() && !japanese; auto &ui = Global::candidate_ui; + if (g_translation_candidates_active) + { + // 译文页不再查译文。已经取出的 glosses 也不能清,退出子模式后原候选还要用。 + return; + } if (!enabled || ui.items.empty()) { if (!g_candidate_translation_signature.empty() || !g_candidate_translation_glosses.empty()) @@ -1487,7 +1505,9 @@ std::string EnglishRankingContextKey() bool ExpandCandidatesKeepingPagePosition() { auto &ui = Global::candidate_ui; - if (IsSpecialModeCompositionActive(GlobalIme::composition.raw_input_with_cases) || !g_inputSession || + // 译文页是一份固定的列表,session 里再多的候选也不属于它。 + if (g_translation_candidates_active || + IsSpecialModeCompositionActive(GlobalIme::composition.raw_input_with_cases) || !g_inputSession || !g_inputSession->expand_initial_candidates()) { return false; @@ -1583,6 +1603,7 @@ void PrepareCandidateList(uint64_t client_id, uint64_t activation_epoch); void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t request_id); void ClearState(); void ProcessSelectionKey(UINT keycode, uint64_t client_id, uint64_t activation_epoch, int forced_index_in_page = -1); +void WaitForCandidateRenderSync(UINT keycode); void ApplyCloudCandidate(const std::string &candidate, const std::string &pinyin, uint64_t generation, const std::optional &query); void ApplyAiCandidate(const std::string &candidate, const std::string &identity, uint64_t generation, @@ -3523,7 +3544,8 @@ void ApplyCloudCandidate(const std::string &candidate, const std::string &pinyin if (!GetConfiguredCloudCandidatesEnabled()) return; // A callback can become stale after enqueueing, while earlier key tasks run. - if (FindCloudRequestOrigin(pinyin, generation).client_id == 0 || !g_inputSession) + // 译文页占用着同一份 items,异步候选必须等它退出再合并,否则会把译文冲掉。 + if (FindCloudRequestOrigin(pinyin, generation).client_id == 0 || !g_inputSession || g_translation_candidates_active) return; if (candidate.empty()) @@ -3592,7 +3614,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) + GlobalIme::composition.creating_word.active || current_identity != identity || g_translation_candidates_active) { (void)0; return; @@ -3642,7 +3664,7 @@ void ApplyEnglishCandidates(std::vector candidates, const std::string !EnglishIme::IsCurrent(input, generation, dedicated_mode) || g_inputSession == nullptr || (!dedicated_mode && g_inputSession->current_scheme_type() != SchemeType::Quanpin && g_inputSession->current_scheme_type() != SchemeType::Shuangpin) || - expected_input != input || GlobalIme::composition.creating_word.active) + expected_input != input || GlobalIme::composition.creating_word.active || g_translation_candidates_active) { return; } @@ -3726,7 +3748,7 @@ void ApplyEnglishCandidates(std::vector candidates, const std::string void ApplyCandidateTranslations(std::vector results, uint64_t generation, bool merge) { if (!EnglishIme::IsTranslationCurrent(generation) || !GetConfiguredCandidateTranslationsEnabled() || - IsUiLessMode() || g_candidate_translation_signature.empty() || + IsUiLessMode() || g_candidate_translation_signature.empty() || g_translation_candidates_active || (g_inputSession && g_inputSession->current_scheme_type() == SchemeType::JapaneseRomaji)) return; @@ -3760,7 +3782,7 @@ void ApplyCandidateTranslations(std::vector resul void ApplyEmojiCandidates(std::vector candidates, const std::string &input, uint64_t generation) { if (!GetConfiguredEmojiMixedInputEnabled() || !EmojiIme::IsCurrent(input, generation) || - g_inputSession == nullptr || + g_inputSession == nullptr || g_translation_candidates_active || (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) @@ -3805,7 +3827,7 @@ void ApplyEmojiCandidates(std::vector candidates, const std::string &i void ApplyKaomojiCandidates(std::vector candidates, const std::string &input, uint64_t generation) { if (!GetConfiguredKaomojiMixedInputEnabled() || !KaomojiIme::IsCurrent(input, generation) || - g_inputSession == nullptr || + g_inputSession == nullptr || g_translation_candidates_active || (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) @@ -3847,6 +3869,83 @@ void ApplyKaomojiCandidates(std::vector candidates, const std::string RefreshCandidatePageUi(true); } +// 把候选框换回 Ctrl+Enter 之前的那一屏。译文页期间输入串一个字都没动,session 里的候选 +// 还是原来那批,所以这里直接把存下来的 items / 页码 / 高亮位放回去即可;随后这颗按键继续 +// 走它本来的流程,就像译文页从来没出现过一样。 +void ExitTranslationCandidateMode() +{ + if (!g_translation_candidates_active) + { + return; + } + g_translation_candidates_active = false; + auto &ui = Global::candidate_ui; + ui.set_items(std::move(g_translation_saved_items)); + g_translation_saved_items.clear(); + ui.page_index = g_translation_saved_page_index; + ui.selected_index_in_page = g_translation_saved_selected_index; + g_translation_saved_page_index = 0; + g_translation_saved_selected_index = 0; + RefreshCandidatePageUi(false); +} + +// Ctrl+Enter:上屏高亮候选右边的那条译文(副候选)。只有一条译义就直接上屏;有多条时把 +// 候选框整个换成这几条译义,空格/数字键照常选一条上屏(见 ProcessSelectionKey 的译文分支)。 +void HandleTranslationCommitKey(uint64_t client_id, uint64_t activation_epoch, uint64_t request_id) +{ + // 拿不到译文时回 NavigationIgnored:这颗键已经被 TSF 吃掉了,必须给一条回复, + // 而且这条回复既不上屏也不给 wch 补标点。 + Global::MsgTypeToTsf = Global::DataFromServerMsgType::NavigationIgnored; + const bool japanese = g_inputSession && g_inputSession->current_scheme_type() == SchemeType::JapaneseRomaji; + if (g_translation_candidates_active || IsUiLessMode() || japanese || !GetConfiguredCandidateTranslationsEnabled() || + Global::candidate_ui.items.empty()) + { + SendCurrentDataToClient(client_id, activation_epoch, request_id); + return; + } + + // 和数字/空格选词一样,先等画面追上已发布的那一页,否则取到的是用户没看见的那条译文。 + WaitForCandidateRenderSync(VK_RETURN); + EnsureCandidatePageReady(); + + auto &ui = Global::candidate_ui; + const size_t index = static_cast((std::max)(0, ui.selected_index_in_page)); + if (index >= ui.page_glosses.size()) + { + SendCurrentDataToClient(client_id, activation_epoch, request_id); + return; + } + const auto senses = FanyImeIpc::SplitTranslationGloss(wstring_to_string(ui.page_glosses[index])); + if (senses.empty()) + { + SendCurrentDataToClient(client_id, activation_epoch, request_id); + return; + } + + if (senses.size() == 1) + { + Global::MsgTypeToTsf = Global::DataFromServerMsgType::CommitExactText; + ui.selected_text = string_to_wstring(CandidateTextForOutput(senses.front())); + // Normal/CommitExactText 的回复由 SendCurrentDataToClient 负责收尾(ClearState)。 + SendCurrentDataToClient(client_id, activation_epoch, request_id); + return; + } + + g_translation_saved_items = ui.items; + g_translation_saved_page_index = ui.page_index; + g_translation_saved_selected_index = ui.selected_index_in_page; + std::vector translation_items; + translation_items.reserve(senses.size()); + for (const auto &sense : senses) + { + translation_items.emplace_back(std::string{}, sense, 0, CandidateSource::Fallback); + } + ui.set_items(std::move(translation_items)); + g_translation_candidates_active = true; + RefreshCandidatePageUi(true); + SendCurrentDataToClient(client_id, activation_epoch, request_id); +} + /** * @brief * @@ -3878,6 +3977,25 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques return; } + if (FanyImeIpc::IsTranslationCommitKey(Global::Keycode, Global::ModifiersDown)) + { + HandleTranslationCommitKey(client_id, activation_epoch, request_id); + return; + } + // 译文页只认选词和翻页/移动高亮。其它任何键都先把候选框换回原来那一屏,然后照常处理, + // 所以退格、字母、回车、标点在译文页上的表现和没按过 Ctrl+Enter 时完全一致。 + if (g_translation_candidates_active) + { + // Shift 放行是给 Shift+Tab 上一页留的;Ctrl/Alt 组合一律退出。 + const bool stays_on_translation_page = + (Global::ModifiersDown & 0b00000110u) == 0 && + (IsSelectionKey(Global::Keycode) || IsCandidateNavigationKey(Global::Keycode)); + if (!stays_on_translation_page) + { + ExitTranslationCandidateMode(); + } + } + if (g_r_mode_triggered && !GlobalIme::composition.raw_input_with_cases.empty() && GlobalIme::composition.raw_input_with_cases.front() == 'R' && GlobalIme::composition.caret_position > 0) { @@ -4309,6 +4427,10 @@ void ClearState() UpdateEnglishInput(""); g_candidate_translation_signature.clear(); g_candidate_translation_glosses.clear(); + g_translation_candidates_active = false; + g_translation_saved_items.clear(); + g_translation_saved_page_index = 0; + g_translation_saved_selected_index = 0; EnglishIme::ClearTranslations(); CloudTranslation::Clear(); UpdateEmojiInput(""); @@ -4406,6 +4528,32 @@ void ProcessSelectionKey(UINT keycode, uint64_t client_id, uint64_t activation_e static bool isNeedUpdateWeight = false; isNeedUpdateWeight = false; + if (g_translation_candidates_active) + { + // 译文页:选中的就是要上屏的完整文本,既不调频也不造词,更不能把译文当候选词学进库。 + if (forced_index_in_page < 0 && (keycode == VK_SPACE || (keycode >= '1' && keycode <= '9'))) + { + WaitForCandidateRenderSync(keycode); + } + EnsureCandidatePageReady(); + auto &ui = Global::candidate_ui; + const int index = forced_index_in_page >= 0 + ? forced_index_in_page + : (keycode == VK_SPACE ? ui.selected_index_in_page : static_cast(keycode - '1')); + if (index < 0 || static_cast(index) >= ui.page_words.size()) + { + // 这一页没有这个序号:什么都不上屏,译文页原样留着。走 SELECT_BY_NUMBER 的 + // TSF 路径只认 Normal / OutofRange 两种回复,这里必须是后者。 + Global::MsgTypeToTsf = Global::DataFromServerMsgType::OutofRange; + return; + } + // 同理,上屏必须回 Normal:_HandleCandidateFinalize 只在 Normal 时写入文本, + // 而且它写的就是 candidate_string 本身,不会再补标点。 + Global::MsgTypeToTsf = Global::DataFromServerMsgType::Normal; + ui.selected_text = ui.page_words[index]; + return; + } + // Keyboard selection must match the painted page. Mouse clicks carry an explicit index // (forced_index_in_page >= 0) and keep the old behavior: waiting cannot restore the intent of a // click that targeted a candidate of a page that is no longer on screen. diff --git a/server/tests/CMakeLists.txt b/server/tests/CMakeLists.txt index a2a1cfca..acdbf44b 100644 --- a/server/tests/CMakeLists.txt +++ b/server/tests/CMakeLists.txt @@ -35,6 +35,7 @@ set(TEST_SOURCE_FILES "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_ui_owner.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_async_request_origin.cpp" "${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_render_sync.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_wheel_paging.cpp" diff --git a/server/tests/src/test_candidate_translation_policy.cpp b/server/tests/src/test_candidate_translation_policy.cpp new file mode 100644 index 00000000..add44764 --- /dev/null +++ b/server/tests/src/test_candidate_translation_policy.cpp @@ -0,0 +1,52 @@ +#include "ipc/candidate_translation_policy.h" +#include "tests/includes/test_framework.h" + +TEST_CASE(ctrl_enter_alone_commits_the_secondary_candidate) +{ + using FanyImeIpc::IsTranslationCommitKey; + REQUIRE(IsTranslationCommitKey(0x0D, FanyImeIpc::kModifierControl)); + // The UI-less flag lives outside the modifier mask and must not disarm the shortcut. + REQUIRE(IsTranslationCommitKey(0x0D, FanyImeIpc::kModifierControl | FanyImeIpc::kModifierUiLess)); + + REQUIRE(!IsTranslationCommitKey(0x0D, 0)); + REQUIRE(!IsTranslationCommitKey(0x0D, FanyImeIpc::kModifierControl | FanyImeIpc::kModifierShift)); + REQUIRE(!IsTranslationCommitKey(0x0D, FanyImeIpc::kModifierControl | FanyImeIpc::kModifierAlt)); + REQUIRE(!IsTranslationCommitKey(0x20, FanyImeIpc::kModifierControl)); + REQUIRE(!IsTranslationCommitKey('J', FanyImeIpc::kModifierControl)); +} + +TEST_CASE(translation_gloss_splits_on_both_semicolon_widths) +{ + using FanyImeIpc::SplitTranslationGloss; + // en->zh glosses join senses with the fullwidth semicolon. + const auto apple = SplitTranslationGloss("苹果;家伙;[计]苹果公司"); + REQUIRE_EQ(apple.size(), static_cast(3)); + REQUIRE_EQ(apple[0], std::string("苹果")); + REQUIRE_EQ(apple[1], std::string("家伙")); + REQUIRE_EQ(apple[2], std::string("[计]苹果公司")); + + // zh->en glosses use "; " and the trailing space must not survive. + const auto chuan = SplitTranslationGloss("string; trail; chuan"); + REQUIRE_EQ(chuan.size(), static_cast(3)); + REQUIRE_EQ(chuan[0], std::string("string")); + REQUIRE_EQ(chuan[1], std::string("trail")); + REQUIRE_EQ(chuan[2], std::string("chuan")); +} + +TEST_CASE(translation_gloss_keeps_a_single_sense_whole) +{ + using FanyImeIpc::SplitTranslationGloss; + // A cloud gloss is one sentence: commas inside it are not sense separators. + const auto cloud = SplitTranslationGloss("今天天气不错,我们出去走走"); + REQUIRE_EQ(cloud.size(), static_cast(1)); + REQUIRE_EQ(cloud[0], std::string("今天天气不错,我们出去走走")); + + REQUIRE(SplitTranslationGloss("").empty()); + REQUIRE(SplitTranslationGloss(" ").empty()); + // Empty senses are dropped rather than becoming blank candidates. + REQUIRE_EQ(SplitTranslationGloss(";;").size(), static_cast(0)); + const auto ragged = SplitTranslationGloss(";apple; ;pear;"); + REQUIRE_EQ(ragged.size(), static_cast(2)); + REQUIRE_EQ(ragged[0], std::string("apple")); + REQUIRE_EQ(ragged[1], std::string("pear")); +} diff --git a/windows/src/Key/KeyEventSink.cpp b/windows/src/Key/KeyEventSink.cpp index 851f361f..7ca6e046 100644 --- a/windows/src/Key/KeyEventSink.cpp +++ b/windows/src/Key/KeyEventSink.cpp @@ -214,6 +214,15 @@ bool IsEnglishInputModeToggle(UINT code, UINT modifiers) return code == 'E' && (modifiers & 0b00000111u) == 0b00000011u; } +// Ctrl+Enter, without Shift/Alt/Windows: commit the translation shown to the right of the +// highlighted candidate. The Server decides what that is (one translation commits directly, +// several open a second candidate list), so this only has to reach it as a candidate key. +bool IsTranslationCommitShortcut(UINT code, UINT modifiers) +{ + return code == VK_RETURN && (modifiers & 0b00000111u) == 0b00000010u && (GetAsyncKeyState(VK_LWIN) & 0x8000) == 0 && + (GetAsyncKeyState(VK_RWIN) & 0x8000) == 0; +} + bool IsCharacterSetInputModeToggle(UINT code, UINT modifiers) { return FanyImeProtocol::IsCharacterSetShortcut(code, modifiers) && (GetAsyncKeyState(VK_LWIN) & 0x8000) == 0 && @@ -744,6 +753,18 @@ BOOL CMetasequoiaIME::_IsKeyEaten( // return TRUE; } + // 候选框开着的时候 Ctrl+Enter 上屏高亮候选的译文;没有候选框时它仍然属于应用。 + if (!freshCompositionState && _candidateMode != CANDIDATE_NONE && + IsTranslationCommitShortcut(*pCodeOut, shortcutModifiers)) + { + if (pKeyState) + { + pKeyState->Category = CATEGORY_CANDIDATE; + pKeyState->Function = FUNCTION_SERVER_CANDIDATE_KEY; + } + return TRUE; + } + // Other Ctrl/Alt/Windows combinations belong to the application. // IME-owned shortcuts are handled before this normal key classifier. if ((shortcutModifiers & 0b00000110u) != 0 || (GetAsyncKeyState(VK_LWIN) & 0x8000) != 0 || @@ -1224,6 +1245,17 @@ bool CMetasequoiaIME::_ClassifyDeferredKeyDown(_In_ ITfContext *pContext, WPARAM return true; } + // 同上:译文上屏也要在这条 Ctrl/Alt 拦截之前分类,否则这颗键会被交还给应用。 + const bool projectedCandidateActive = + _deferredKeyProjectionValid ? _deferredProjectedCandidateActive : (_candidateMode == CANDIDATE_ORIGINAL); + if (projectedImeOpen && !_IsKeyboardDisabled() && projectedCandidateActive && + IsTranslationCommitShortcut(*classifiedCode, capturedModifiers)) + { + keyState->Category = CATEGORY_CANDIDATE; + keyState->Function = FUNCTION_SERVER_CANDIDATE_KEY; + return true; + } + // Other Ctrl/Alt/Windows combinations belong to the application. In particular, // never turn a recovery FIFO into a shortcut sink. if ((capturedModifiers & 0b00000110) != 0 || (GetAsyncKeyState(VK_LWIN) & 0x8000) != 0 ||