Merge pull request #417 from fanlusky/feat/candidate-translation-commit

feat(candidate): Ctrl+Enter 上屏候选右侧的译文
This commit is contained in:
Lu Fan(SonnyCalcr) authored and GitHub committed 2026-09-17 20:48:27 +08:00
commit ca9cfe136b
6 files changed
+299 -12

No files matched your search

+1
View File
@@ -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"
@@ -0,0 +1,53 @@
#pragma once
#include <cstdint>
#include <string>
#include <string_view>
#include <vector>
#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<std::string> SplitTranslationGloss(std::string_view gloss)
{
static constexpr std::string_view kFullwidthSemicolon = "\xEF\xBC\x9B";
std::vector<std::string> 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
+160 -12
View File
@@ -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<std::string, std::string> g_candidate_translation_glosses;
std::string g_candidate_translation_signature;
// 副候选框:Ctrl+Enter 在高亮候选有多条译义时,把候选框整个换成那几条译义,让空格/
// 数字键像选普通候选一样选一条上屏。输入串一个字都没动,所以退出这个子模式时把原来的
// items / 页码 / 高亮位原样放回去就行,不需要重新查词。
bool g_translation_candidates_active = false;
std::vector<WordItem> g_translation_saved_items;
int g_translation_saved_page_index = 0;
int g_translation_saved_selected_index = 0;
std::shared_ptr<IInputSession> 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<metasequoia::OnlineQuery> &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<WordItem> 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<WordItem> candidates, const std::string
void ApplyCandidateTranslations(std::vector<EnglishIme::TranslationResult> 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<EnglishIme::TranslationResult> resul
void ApplyEmojiCandidates(std::vector<WordItem> 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<WordItem> candidates, const std::string &i
void ApplyKaomojiCandidates(std::vector<WordItem> 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<WordItem> 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<size_t>((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<WordItem> 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<int>(keycode - '1'));
if (index < 0 || static_cast<size_t>(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.
+1
View File
@@ -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"
@@ -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<size_t>(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<size_t>(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<size_t>(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<size_t>(0));
const auto ragged = SplitTranslationGloss(";apple; ;pear;");
REQUIRE_EQ(ragged.size(), static_cast<size_t>(2));
REQUIRE_EQ(ragged[0], std::string("apple"));
REQUIRE_EQ(ragged[1], std::string("pear"));
}
+32
View File
@@ -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 ||