feat(server,windows,engine): 横排候选时左右方向键移动高亮 (#1080)

- 快捷键页「翻页方式」新增「左 / 右(横排时移动候选项)」,与「上 / 下」各自独立,默认关闭,
  只在候选窗为横排时生效。配置项 general.candidate_horizontal_arrow_navigation 已写入出厂模板。
- 光标在拼音串尾时右键移动高亮(到页末翻页),左键往回移高亮,高亮回到第一个候选后再按才移动光标;
  光标在串中间时右键先把光标移回串尾,再按才移动高亮。Ctrl+左/右与 UILess 宿主不变。
- 新增 worker 消息 HorizontalArrowSelectionChanged(39)和按键标记 FanyImePipeFlags::HorizontalArrowSelection:
  开关生效时 TSF 把候选态下的左右键交给 Server 决定,移光标时 Server 用 CompositionRestored 回光标,
  移高亮时回 MoveSelection*,TSF 不再自己预测光标。旧 DLL 忽略新消息,保持原行为。

Closes #779
Closes #895
This commit is contained in:
Lu Fan(SonnyCalcr) authored and GitHub committed 2026-10-19 02:11:25 +08:00
1 parent 6e15526e75
commit c0a9d371f0
25 files changed
+257 -32

No files matched your search

@@ -45,7 +45,7 @@ int main()
CHECK((FanyImeProtocol::Negotiate(FanyImeProtocol::Hello(7, 124), rCapabilities).capabilities &
FanyImeProtocol::TemporaryJapaneseLongVowel) == 0);
CHECK(FanyImeWorkerReplyType::TemporaryJapanesePolicyChanged == 36);
CHECK(FanyImeWorkerReplyType::MaxKnown == 38);
CHECK(FanyImeWorkerReplyType::MaxKnown == 39);
for (wchar_t payload = L'0'; payload <= L'3'; ++payload)
{
CHECK(FanyImeTemporaryJapanese::IsValidPayload(payload));
@@ -241,7 +241,8 @@ int main()
CHECK(FanyImeWorkerReplyType::TemporaryJapanesePolicyChanged == 36);
CHECK(FanyImeWorkerReplyType::EndHelpcodeTriggersChanged == 37);
CHECK(FanyImeWorkerReplyType::EnumerationCommaKeysChanged == 38);
CHECK(FanyImeWorkerReplyType::MaxKnown == FanyImeWorkerReplyType::EnumerationCommaKeysChanged);
CHECK(FanyImeWorkerReplyType::HorizontalArrowSelectionChanged == 39);
CHECK(FanyImeWorkerReplyType::MaxKnown == FanyImeWorkerReplyType::HorizontalArrowSelectionChanged);
// EnumerationCommaKeysChanged 的载荷:\ 和 / 各一位,两个都没勾按只有 \ 发。
CHECK(FanyImeEnumerationComma::PayloadFor(true, false) == L'1' &&
FanyImeEnumerationComma::PayloadFor(false, true) == L'2' &&
@@ -269,6 +270,12 @@ int main()
CHECK((FanyImePipeFlags::SensitiveField &
(FanyImePipeFlags::UiLess | FanyImePipeFlags::ImeSwitchCapsSnapshotPresent |
FanyImePipeFlags::ImeSwitchCapsSnapshotEnabled | FanyImeTrilingualInput::CycleRequestMask)) == 0);
// Same for the horizontal arrow selection flag, which also stays clear of the sensitive-field bit.
CHECK((FanyImePipeFlags::HorizontalArrowSelection & 7u) == 0);
CHECK((FanyImePipeFlags::HorizontalArrowSelection &
(FanyImePipeFlags::UiLess | FanyImePipeFlags::ImeSwitchCapsSnapshotPresent |
FanyImePipeFlags::ImeSwitchCapsSnapshotEnabled | FanyImeTrilingualInput::CycleRequestMask |
FanyImePipeFlags::SensitiveField)) == 0);
// V 模式的形状规则,TSF(WCHAR)与 Server(char)共用。
{
using FanyImeVModeInput::Trigger;
+14 -2
View File
@@ -158,6 +158,12 @@ constexpr std::uint32_t ImeSwitchCapsSnapshotEnabled = 0x20000000u;
// receive it because the DLL only sets it after the new opcode turned it on.
// 0x10000000..0x04000000 belong to FanyImeTrilingualInput.
constexpr std::uint32_t SensitiveField = 0x02000000u;
// KeyEvent only, plain Left / Right while candidates are shown: the DLL asks the Server to decide between moving
// the candidate highlight and moving the composition caret (horizontal candidate window, see
// HorizontalArrowSelectionChanged). The Server answers a highlight move with MoveSelectionPrevious / MoveSelectionNext
// and a caret move with CompositionRestored carrying the caret. The DLL only sets it after the Server turned the
// feature on and CompositionRestore was negotiated; the Server strips it before any key-modifier policy runs.
constexpr std::uint32_t HorizontalArrowSelection = 0x01000000u;
constexpr std::uint32_t EncodeImeSwitchCapsLockSnapshot(bool enabled)
{
@@ -336,7 +342,8 @@ constexpr std::uint32_t ProtocolMismatch = 13;
// The Server retracted or repositioned an in-progress word and restored its raw
// pinyin: this is the authoritative reply for the retraction (Backspace), the
// unit deletion (Ctrl+Backspace) and the unit caret move (Ctrl+Left /
// Ctrl+Right). candidate_string =
// Ctrl+Right), and for a plain Left / Right sent with
// FanyImePipeFlags::HorizontalArrowSelection that moved the caret. candidate_string =
// remaining_raw \t committed_word \t display_preedit [\t caret]
// caret is an optional decimal offset into remaining_raw; a 3-field payload
// means "caret at the end". NeedToCreateWord and CompositionRestored share the
@@ -467,7 +474,12 @@ constexpr std::uint32_t EndHelpcodeTriggersChanged = 37;
// FanyImeEnumerationComma::PayloadFor: bit 1 '\', bit 2 '/'. A key whose bit is clear is not Chinese punctuation and
// passes through as itself. Older DLLs ignore the opcode and keep '\' only.
constexpr std::uint32_t EnumerationCommaKeysChanged = 38;
constexpr std::uint32_t MaxKnown = EnumerationCommaKeysChanged;
// Whether plain Left / Right move the candidate highlight while the candidate window is horizontal. Payload "0"/"1":
// "1" only when the setting is on and the configured layout is horizontal. With it on the DLL sends those keys with
// FanyImePipeFlags::HorizontalArrowSelection and applies the Server's answer instead of moving the caret itself.
// Older DLLs ignore the opcode and keep moving the caret.
constexpr std::uint32_t HorizontalArrowSelectionChanged = 39;
constexpr std::uint32_t MaxKnown = HorizontalArrowSelectionChanged;
// Source compatibility for the Server's historical spellings.
constexpr std::uint32_t SwitchToEn = SwitchToEnglish;
constexpr std::uint32_t SwitchToCn = SwitchToChinese;
@@ -50,6 +50,8 @@ paging_page_up_down = true
# 依赖 Windows「当我悬停在非活动窗口上方时滚动它们」,该系统设置关闭时本项不生效
paging_mouse_wheel = false
candidate_arrow_navigation = true
# 横排候选时左右方向键移动高亮:光标在拼音串尾时右键移高亮,左键回到第一个候选后再按才移动光标(默认关闭)
candidate_horizontal_arrow_navigation = false
# 中文输入状态下显示英文前缀候选
cn_en_mixed_input = true
# 中英混输触发所需的最少字母数,范围 1–8
+2
View File
@@ -51,6 +51,8 @@ paging_page_up_down = true
# 依赖 Windows「当我悬停在非活动窗口上方时滚动它们」,该系统设置关闭时本项不生效
paging_mouse_wheel = false
candidate_arrow_navigation = true
# 横排候选时左右方向键移动高亮:光标在拼音串尾时右键移高亮,左键回到第一个候选后再按才移动光标(默认关闭)
candidate_horizontal_arrow_navigation = false
# 中文输入状态下显示英文前缀候选
cn_en_mixed_input = true
# 中英混输触发所需的最少字母数,范围 1–8
+3
View File
@@ -197,6 +197,7 @@ bool g_paging_page_up_down_enabled = true;
// "scroll inactive windows on hover" is on, so the feature is opt-in.
bool g_paging_mouse_wheel_enabled = false;
bool g_candidate_arrow_navigation_enabled = true;
bool g_candidate_horizontal_arrow_navigation_enabled = false;
bool g_word_to_character_enabled = false;
std::string g_word_to_character_keys = "brackets";
bool g_smart_punctuation_enabled = false;
@@ -679,6 +680,8 @@ bool LoadImeConfig()
g_paging_page_up_down_enabled = tbl["general"]["paging_page_up_down"].value_or(true);
g_paging_mouse_wheel_enabled = tbl["general"]["paging_mouse_wheel"].value_or(false);
g_candidate_arrow_navigation_enabled = tbl["general"]["candidate_arrow_navigation"].value_or(true);
g_candidate_horizontal_arrow_navigation_enabled =
tbl["general"]["candidate_horizontal_arrow_navigation"].value_or(false);
g_word_to_character_enabled = tbl["input"]["word_to_character"].value_or(false);
g_word_to_character_keys = tbl["input"]["word_to_character_keys"].value_or(std::string("brackets"));
if (g_word_to_character_keys != "minus_equal")
+6
View File
@@ -605,6 +605,12 @@ bool GetConfiguredPagingMouseWheelEnabled();
bool SetConfiguredPagingMouseWheelEnabled(bool enabled);
bool GetConfiguredCandidateArrowNavigationEnabled();
bool SetConfiguredCandidateArrowNavigationEnabled(bool enabled);
// 横排候选时左右方向键移动高亮(general.candidate_horizontal_arrow_navigation),默认关;
// 只在候选窗布局为 horizontal 时生效,生效与否经 HorizontalArrowSelectionChanged 发给 TSF。
bool GetConfiguredCandidateHorizontalArrowNavigationEnabled();
bool SetConfiguredCandidateHorizontalArrowNavigationEnabled(bool enabled);
// Worker payload for HorizontalArrowSelectionChanged: "1" when the setting is on and the layout is horizontal.
std::wstring FormatHorizontalArrowSelectionWorkerPayload();
bool GetConfiguredWordToCharacterEnabled();
bool SetConfiguredWordToCharacterEnabled(bool enabled);
std::string GetConfiguredWordToCharacterKeys();
+1
View File
@@ -238,6 +238,7 @@ extern bool g_paging_tab_enabled;
extern bool g_paging_page_up_down_enabled;
extern bool g_paging_mouse_wheel_enabled;
extern bool g_candidate_arrow_navigation_enabled;
extern bool g_candidate_horizontal_arrow_navigation_enabled;
extern bool g_word_to_character_enabled;
extern std::string g_word_to_character_keys;
extern bool g_smart_punctuation_enabled;
+22 -1
View File
@@ -1,4 +1,5 @@
// 按键行为配置:翻页键、方向键选词、以词定字、智能标点、配对标点、顿号键与标点锁定,以及发给 TSF 的对应载荷。
// 按键行为配置:翻页键、方向键选词(含横排左右键)、以词定字、智能标点、配对标点、顿号键与标点锁定,
// 以及发给 TSF 的对应载荷。
#include "config/ime_config_internal.h"
#include <string>
#include "engine/contracts/enumeration_comma.h"
@@ -125,6 +126,26 @@ bool SetConfiguredCandidateArrowNavigationEnabled(bool enabled)
return true;
}
bool GetConfiguredCandidateHorizontalArrowNavigationEnabled()
{
return g_candidate_horizontal_arrow_navigation_enabled;
}
bool SetConfiguredCandidateHorizontalArrowNavigationEnabled(bool enabled)
{
if (!WriteConfiguredValue("general", "candidate_horizontal_arrow_navigation", enabled ? "true" : "false"))
{
return false;
}
g_candidate_horizontal_arrow_navigation_enabled = enabled;
return true;
}
std::wstring FormatHorizontalArrowSelectionWorkerPayload()
{
return g_candidate_horizontal_arrow_navigation_enabled && g_candidate_window_layout == "horizontal" ? L"1" : L"0";
}
bool GetConfiguredWordToCharacterEnabled()
{
return g_word_to_character_enabled;
+46 -8
View File
@@ -1002,6 +1002,10 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques
const bool sensitive_field = (Global::ModifiersDown & FanyImePipeFlags::SensitiveField) != 0;
Global::ModifiersDown &= ~FanyImePipeFlags::SensitiveField;
NoteAiContextKeySource(client_id, activation_epoch, sensitive_field);
// 横排候选的左右键:TSF 只在这项生效、且协商过 CompositionRestore 时打这个标记,并且一定会读这次的回复。
// 是否生效以标记为准而不是再查配置:配置刚改、TSF 还没收到新状态时,两边仍按同一个约定走。
const bool horizontal_arrow_request = (Global::ModifiersDown & FanyImePipeFlags::HorizontalArrowSelection) != 0;
Global::ModifiersDown &= ~FanyImePipeFlags::HorizontalArrowSelection;
// MainPipeClientThread only enqueues this from a negotiated client with a known action.
if (namedpipeData.event_type == FanyImePipeEventType::InputStateAction)
@@ -1067,13 +1071,29 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques
HandleTranslationCommitKey(client_id, activation_epoch, request_id, static_cast<int>(digit_position) - 1);
return;
}
// 横排候选的左右键在这里决定是移高亮还是移光标(规则见 ShouldArrowMoveCandidateSelection)。要在下面 R 模式
// 把光标换回引擎坐标之前判断:此刻光标和 raw_input_with_cases 都还是含 R 前缀的显示坐标,串尾的含义一致。
const bool horizontal_arrow_key =
horizontal_arrow_request && (Global::Keycode == VK_LEFT || Global::Keycode == VK_RIGHT) &&
(Global::ModifiersDown & 0b00000111u) == 0 && !IsUiLessMode() && ClientNegotiatedCompositionRestore(client_id);
bool arrow_moves_selection = false;
if (horizontal_arrow_key)
{
const auto &composition = GlobalIme::composition;
const auto &ui = Global::candidate_ui;
arrow_moves_selection = FanyImeIpc::ShouldArrowMoveCandidateSelection(
Global::Keycode, composition.caret_position >= composition.raw_input_with_cases.size(),
ui.current_page_start() + ui.selected_index_in_page, ui.item_total_count);
}
// 译文页、日期页只认选词和翻页/移动高亮。其它任何键都先把候选框换回原来那一屏,然后照常处理,
// 所以退格、字母、回车、标点在子页上的表现和没打开过它时完全一致。
if (IsCandidateSubPageActive())
{
// Shift 放行是给 Shift+Tab 上一页留的;Ctrl/Alt 组合一律退出。
const bool stays_on_sub_page = (Global::ModifiersDown & 0b00000110u) == 0 &&
(IsSelectionKey(Global::Keycode) || IsCandidateNavigationKey(Global::Keycode));
const bool stays_on_sub_page =
(Global::ModifiersDown & 0b00000110u) == 0 &&
(IsSelectionKey(Global::Keycode) || IsCandidateNavigationKey(Global::Keycode) || arrow_moves_selection);
if (!stays_on_sub_page)
{
ExitCandidateSubPage();
@@ -1210,11 +1230,13 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques
!is_v_mode_shift_digit_selection &&
IsCommitWithHighlightedCandidatePunctuationInCandidateMode(Global::Keycode, Global::Wch));
const bool is_selection_key = IsSelectionKey(Global::Keycode);
// 移高亮的左右键不碰组合,交给下面的候选导航分支。
const bool is_composition_edit_key =
Global::Keycode == VK_LEFT || Global::Keycode == VK_RIGHT || Global::Keycode == VK_BACK ||
Global::Keycode == VK_DELETE || (Global::Keycode >= 'A' && Global::Keycode <= 'Z') ||
is_manual_pinyin_separator || is_microsoft_shuangpin_ing_key || is_mid_sentence_helpcode_marker ||
is_direct_helpcode_slash || is_japanese_long_vowel || is_date_time_input_key || is_v_mode_input_key;
((Global::Keycode == VK_LEFT || Global::Keycode == VK_RIGHT) && !arrow_moves_selection) ||
Global::Keycode == VK_BACK || Global::Keycode == VK_DELETE ||
(Global::Keycode >= 'A' && Global::Keycode <= 'Z') || is_manual_pinyin_separator ||
is_microsoft_shuangpin_ing_key || is_mid_sentence_helpcode_marker || is_direct_helpcode_slash ||
is_japanese_long_vowel || is_date_time_input_key || is_v_mode_input_key;
const bool should_forward_key_to_session = !is_commit_with_highlighted_candidate_punctuation && !is_selection_key &&
!is_paging_key && !is_composition_edit_key;
@@ -1316,6 +1338,11 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques
ScopedKeyStage engine_edit{client_id, activation_epoch, request_id, L"handle-engine"};
ApplyCompositionEditKey(Global::Keycode, Global::Wch, Global::ModifiersDown, client_supports_restore,
composition_restored);
// 横排候选左右键移的是光标:TSF 不再自己移,等 CompositionRestored 里的光标(原地不动也要回这一帧)。
if (horizontal_arrow_key)
{
composition_restored = true;
}
}
else if (should_forward_key_to_session)
{
@@ -1669,7 +1696,7 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques
select_space_digit.Stop();
SendCurrentDataToClient(client_id, activation_epoch, request_id);
}
else if (Global::Keycode == VK_LEFT || Global::Keycode == VK_RIGHT)
else if ((Global::Keycode == VK_LEFT || Global::Keycode == VK_RIGHT) && !arrow_moves_selection)
{
if (IsUiLessMode())
{
@@ -1712,7 +1739,7 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques
}
}
}
else if (IsCandidateNavigationKey(Global::Keycode) && !is_v_mode_input_key)
else if ((IsCandidateNavigationKey(Global::Keycode) || arrow_moves_selection) && !is_v_mode_input_key)
{
auto &ui = Global::candidate_ui;
UINT result = Global::DataFromServerMsgType::NavigationIgnored;
@@ -1782,6 +1809,17 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques
{
move_page(1, Global::DataFromServerMsgType::MovePageNext);
}
else if (arrow_moves_selection)
{
if (Global::Keycode == VK_LEFT)
{
move_selection(-1, Global::DataFromServerMsgType::MoveSelectionPrevious);
}
else
{
move_selection(1, Global::DataFromServerMsgType::MoveSelectionNext);
}
}
else if (GetConfiguredCandidateArrowNavigationEnabled() &&
(Global::Keycode == VK_UP || Global::Keycode == VK_DOWN))
{
+3
View File
@@ -868,6 +868,9 @@ void RegisteredPipeMonitorThread(HANDLE clientPipe, UINT pipeRole, uint64_t hand
SendToTsfWorkerThreadClientViaNamedpipe(
hello.client_id, Global::DataFromServerMsgTypeToTsfWorkerThread::EnumerationCommaKeysChanged,
FormatEnumerationCommaWorkerPayload());
SendToTsfWorkerThreadClientViaNamedpipe(
hello.client_id, Global::DataFromServerMsgTypeToTsfWorkerThread::HorizontalArrowSelectionChanged,
FormatHorizontalArrowSelectionWorkerPayload());
SendToTsfWorkerThreadClientViaNamedpipe(
hello.client_id, Global::DataFromServerMsgTypeToTsfWorkerThread::MicrosoftShuangpinChanged,
IsConfiguredShuangpinSemicolonFinal() ? L"1" : L"0");
+17
View File
@@ -341,6 +341,23 @@ constexpr bool IsSegmentCaretKey(uint32_t keycode, uint32_t modifiers_down)
(modifiers_down & kKeyModifierMask) == kModifierControl;
}
// 横排候选的左右方向键(TSF 打了 FanyImePipeFlags::HorizontalArrowSelection 才会问):把拼音光标和候选高亮看成
// 一条线。光标不在串尾时两个键都照常移动光标(右键先回到串尾);光标在串尾时右键移动高亮,左键在高亮离开第一个
// 候选时往回移高亮,已经在第一个候选上才去移动光标。selected_index 是高亮在全部候选里的绝对位置。
constexpr bool ShouldArrowMoveCandidateSelection(uint32_t keycode, bool caret_at_end, int selected_index,
int candidate_count) noexcept
{
if (!caret_at_end || candidate_count <= 0)
{
return false;
}
if (keycode == kVirtualKeyRight)
{
return true;
}
return keycode == kVirtualKeyLeft && selected_index > 0;
}
// A segment Backspace with nothing left before the caret deletes the last
// selected segment of the word being created: the accumulated word returns to
// its pre-selection state and the spelling that segment consumed is discarded
@@ -1094,15 +1094,18 @@ VisualPtr BuildShortcutPage(PageContext &context)
// 翻页方式
{
auto column = std::make_shared<Column>(0.0f);
column->Add(Heading(L"翻页方式", L"勾选启用对应的候选翻页键。滚轮翻页依赖系统设置「当我悬停在非活动窗口上方时"
L"滚动它们」,该项关闭时滚轮不会到达候选窗口"));
column->Add(Options({BindCheck(context, L"- / =", "general.paging_minus_equal"),
BindCheck(context, L", / .", "general.paging_comma_period"),
BindCheck(context, L"[ / ]", "general.paging_brackets"),
BindCheck(context, L"Shift+Tab / Tab", "general.paging_tab"),
BindCheck(context, L"PageUp / PageDown", "general.paging_page_up_down"),
BindCheck(context, L"上 / 下(移动候选项)", "general.candidate_arrow_navigation"),
BindCheck(context, L"鼠标滚轮", "general.paging_mouse_wheel")}));
column->Add(Heading(L"翻页方式", L"勾选启用对应的候选翻页键。左 / 右只在候选窗为横排时生效:光标在拼音末尾时"
L"右键移动高亮,左键回到第一个候选后再按才移动光标。滚轮翻页依赖系统设置"
L"「当我悬停在非活动窗口上方时滚动它们」,该项关闭时滚轮不会到达候选窗口"));
column->Add(Options(
{BindCheck(context, L"- / =", "general.paging_minus_equal"),
BindCheck(context, L", / .", "general.paging_comma_period"),
BindCheck(context, L"[ / ]", "general.paging_brackets"),
BindCheck(context, L"Shift+Tab / Tab", "general.paging_tab"),
BindCheck(context, L"PageUp / PageDown", "general.paging_page_up_down"),
BindCheck(context, L"上 / 下(移动候选项)", "general.candidate_arrow_navigation"),
BindCheck(context, L"左 / 右(横排时移动候选项)", "general.candidate_horizontal_arrow_navigation"),
BindCheck(context, L"鼠标滚轮", "general.paging_mouse_wheel")}));
page.AddRaw(std::make_shared<FlatSection>(column), 18.0f);
}
+4 -1
View File
@@ -384,7 +384,8 @@ std::wstring BuildConfigMessage(bool refresh_skin_catalog)
{"paging_tab", GetConfiguredPagingTabEnabled()},
{"paging_page_up_down", GetConfiguredPagingPageUpDownEnabled()},
{"paging_mouse_wheel", GetConfiguredPagingMouseWheelEnabled()},
{"candidate_arrow_navigation", GetConfiguredCandidateArrowNavigationEnabled()}}},
{"candidate_arrow_navigation", GetConfiguredCandidateArrowNavigationEnabled()},
{"candidate_horizontal_arrow_navigation", GetConfiguredCandidateHorizontalArrowNavigationEnabled()}}},
{"association",
{{"sentence_wordlattice", GetConfiguredAssocSentenceWordLattice()},
{"sentence_google", GetConfiguredAssocSentenceGoogle()},
@@ -879,6 +880,8 @@ bool ApplyConfigUpdate(const json::object &data)
return SetConfiguredPagingMouseWheelEnabled(json::value_to<bool>(data.at("value")));
if (path == "general.candidate_arrow_navigation")
return SetConfiguredCandidateArrowNavigationEnabled(json::value_to<bool>(data.at("value")));
if (path == "general.candidate_horizontal_arrow_navigation")
return SetConfiguredCandidateHorizontalArrowNavigationEnabled(json::value_to<bool>(data.at("value")));
if (path == "keybindings.switch_language_shift")
return SetConfiguredSwitchLanguageShiftEnabled(json::value_to<bool>(data.at("value")));
if (path == "keybindings.switch_language_ctrl")
@@ -466,6 +466,7 @@ LRESULT CALLBACK WndProcCandWindow(HWND hwnd, UINT message, WPARAM wParam, LPARA
GetConfiguredSmartPunctuationRepeatToChineseEnabled();
const bool previous_paired_punctuation = GetConfiguredPairedPunctuationEnabled();
const std::wstring previous_enumeration_comma = FormatEnumerationCommaWorkerPayload();
const std::wstring previous_horizontal_arrow_selection = FormatHorizontalArrowSelectionWorkerPayload();
const std::string previous_punctuation_lock = GetConfiguredPunctuationLock();
const bool previous_tsf_diagnostic_log = GetConfiguredTsfDiagnosticLogEnabled();
const bool previous_statistics_enabled = GetConfiguredStatisticsEnabled();
@@ -621,6 +622,13 @@ LRESULT CALLBACK WndProcCandWindow(HWND hwnd, UINT message, WPARAM wParam, LPARA
Global::DataFromServerMsgTypeToTsfWorkerThread::EnumerationCommaKeysChanged,
FormatEnumerationCommaWorkerPayload());
}
// 开关和候选窗布局任一变化都可能改变生效状态。
if (previous_horizontal_arrow_selection != FormatHorizontalArrowSelectionWorkerPayload())
{
BroadcastToTsfWorkerThreadViaNamedpipe(
Global::DataFromServerMsgTypeToTsfWorkerThread::HorizontalArrowSelectionChanged,
FormatHorizontalArrowSelectionWorkerPayload());
}
if (previous_punctuation_lock != GetConfiguredPunctuationLock())
{
BroadcastToTsfWorkerThreadViaNamedpipe(
@@ -301,6 +301,27 @@ TEST_CASE(segment_caret_move_is_ctrl_only)
REQUIRE(!IsSegmentCaretKey('A', FanyImeIpc::kModifierControl));
}
TEST_CASE(horizontal_arrow_moves_highlight_only_from_the_end_of_the_spelling)
{
using FanyImeIpc::kVirtualKeyLeft;
using FanyImeIpc::kVirtualKeyRight;
using FanyImeIpc::ShouldArrowMoveCandidateSelection;
// 光标在串尾:右键总是移高亮(到最后一个候选时由选词逻辑原地不动),左键离开第一个候选才移高亮。
REQUIRE(ShouldArrowMoveCandidateSelection(kVirtualKeyRight, true, 0, 5));
REQUIRE(ShouldArrowMoveCandidateSelection(kVirtualKeyRight, true, 4, 5));
REQUIRE(ShouldArrowMoveCandidateSelection(kVirtualKeyLeft, true, 1, 5));
// 跨页之后的高亮按绝对位置算,第二页第一个候选左移回到第一页。
REQUIRE(ShouldArrowMoveCandidateSelection(kVirtualKeyLeft, true, 8, 20));
// 已在第一个候选:左键交给光标。
REQUIRE(!ShouldArrowMoveCandidateSelection(kVirtualKeyLeft, true, 0, 5));
// 光标在串中间:右键先把光标移回串尾,左键继续移光标。
REQUIRE(!ShouldArrowMoveCandidateSelection(kVirtualKeyRight, false, 0, 5));
REQUIRE(!ShouldArrowMoveCandidateSelection(kVirtualKeyLeft, false, 3, 5));
// 没有候选时两个键都是光标键。
REQUIRE(!ShouldArrowMoveCandidateSelection(kVirtualKeyRight, true, 0, 0));
REQUIRE(!ShouldArrowMoveCandidateSelection('A', true, 1, 5));
}
TEST_CASE(segment_caret_boundaries_stop_at_the_unit_next_to_the_caret)
{
using FanyImeIpc::NextSegmentBoundary;
@@ -69,7 +69,8 @@ TEST_CASE(ipc_pipe_ready_is_a_distinct_server_reply)
REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::TemporaryJapanesePolicyChanged, 36u);
REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::EndHelpcodeTriggersChanged, 37u);
REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::EnumerationCommaKeysChanged, 38u);
REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::MaxKnown, 38u);
REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::HorizontalArrowSelectionChanged, 39u);
REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::MaxKnown, 39u);
REQUIRE(Global::DataFromServerMsgTypeToTsfWorkerThread::FocusSessionReady >
Global::DataFromServerMsgTypeToTsfWorkerThread::PagingCommaPeriodChanged);
REQUIRE(Global::DataFromServerMsgTypeToTsfWorkerThread::PipeReady >
@@ -128,8 +129,10 @@ TEST_CASE(ipc_pipe_ready_is_a_distinct_server_reply)
Global::DataFromServerMsgTypeToTsfWorkerThread::TemporaryJapanesePolicyChanged);
REQUIRE(Global::DataFromServerMsgTypeToTsfWorkerThread::EnumerationCommaKeysChanged >
Global::DataFromServerMsgTypeToTsfWorkerThread::EndHelpcodeTriggersChanged);
REQUIRE(Global::DataFromServerMsgTypeToTsfWorkerThread::HorizontalArrowSelectionChanged >
Global::DataFromServerMsgTypeToTsfWorkerThread::EnumerationCommaKeysChanged);
REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::MaxKnown,
Global::DataFromServerMsgTypeToTsfWorkerThread::EnumerationCommaKeysChanged);
Global::DataFromServerMsgTypeToTsfWorkerThread::HorizontalArrowSelectionChanged);
}
TEST_CASE(ipc_client_suspension_is_a_distinct_nonterminal_route_reset)
@@ -342,7 +342,15 @@ BOOL CCompositionProcessorEngine::IsVirtualKeyNeed( //
if (pKeyState)
{
pKeyState->Category = CATEGORY_COMPOSING;
pKeyState->Function = uCode == VK_LEFT ? FUNCTION_MOVE_LEFT : FUNCTION_MOVE_RIGHT;
// 横排候选左右键选词开着时,移高亮还是移光标由 Server 定,TSF 只应用它的回复。
if (UsesHorizontalArrowSelection())
{
pKeyState->Function = uCode == VK_LEFT ? FUNCTION_MOVE_LEFT_CANDIDATE : FUNCTION_MOVE_RIGHT_CANDIDATE;
}
else
{
pKeyState->Function = uCode == VK_LEFT ? FUNCTION_MOVE_LEFT : FUNCTION_MOVE_RIGHT;
}
}
return TRUE;
}
@@ -90,7 +90,13 @@ enum KEYSTROKE_FUNCTION
// Server-authoritative composition text and language destination.
FUNCTION_CYCLE_INPUT_MODE,
FUNCTION_TOGGLE_ENGLISH_CANDIDATES
FUNCTION_TOGGLE_ENGLISH_CANDIDATES,
// Plain Left / Right while a horizontal candidate window is shown (UsesHorizontalArrowSelection): the Server
// either moves the highlight or moves the caret and answers with the authoritative caret. Append-only, like
// every function above.
FUNCTION_MOVE_LEFT_CANDIDATE,
FUNCTION_MOVE_RIGHT_CANDIDATE
};
//---------------------------------------------------------------------
+7 -1
View File
@@ -415,7 +415,8 @@ void CMetasequoiaIME::IpcWorkerThread(CMetasequoiaIME *pIME)
buf.msg_type == Global::DataToTsfWorkerThreadMsgType::CapsLockChanged ||
buf.msg_type == Global::DataToTsfWorkerThreadMsgType::TsfDiagnosticLogChanged ||
buf.msg_type == Global::DataToTsfWorkerThreadMsgType::StatisticsEnabledChanged ||
buf.msg_type == Global::DataToTsfWorkerThreadMsgType::AiContextUploadChanged))
buf.msg_type == Global::DataToTsfWorkerThreadMsgType::AiContextUploadChanged ||
buf.msg_type == Global::DataToTsfWorkerThreadMsgType::HorizontalArrowSelectionChanged))
{
bool hasTerminator = false;
for (const wchar_t ch : buf.data)
@@ -517,6 +518,7 @@ void CMetasequoiaIME::IpcWorkerThread(CMetasequoiaIME *pIME)
buf.msg_type == Global::DataToTsfWorkerThreadMsgType::MidSentenceHelpcodeUppercaseChanged ||
buf.msg_type == Global::DataToTsfWorkerThreadMsgType::EndHelpcodeTriggersChanged ||
buf.msg_type == Global::DataToTsfWorkerThreadMsgType::EnumerationCommaKeysChanged ||
buf.msg_type == Global::DataToTsfWorkerThreadMsgType::HorizontalArrowSelectionChanged ||
buf.msg_type == Global::DataToTsfWorkerThreadMsgType::InputModeChanged ||
buf.msg_type == Global::DataToTsfWorkerThreadMsgType::TrilingualCycleChanged ||
buf.msg_type == Global::DataToTsfWorkerThreadMsgType::CapsLockChanged ||
@@ -641,6 +643,10 @@ void CMetasequoiaIME::IpcWorkerThread(CMetasequoiaIME *pIME)
{
Global::PairedPunctuationEnabled.store(buf.data[0] == L'1', std::memory_order_relaxed);
}
else if (buf.msg_type == Global::DataToTsfWorkerThreadMsgType::HorizontalArrowSelectionChanged)
{
Global::HorizontalArrowSelectionEnabled.store(buf.data[0] == L'1', std::memory_order_relaxed);
}
else if (buf.msg_type == Global::DataToTsfWorkerThreadMsgType::MicrosoftShuangpinChanged)
{
Global::MicrosoftShuangpinEnabled.store(buf.data[0] == L'1', std::memory_order_relaxed);
+6
View File
@@ -986,6 +986,12 @@ bool SupportsCompositionRestore()
(negotiatedServerCapabilities & FanyImeProtocol::CompositionRestore) != 0;
}
bool UsesHorizontalArrowSelection()
{
return Global::HorizontalArrowSelectionEnabled.load(std::memory_order_relaxed) && !Global::IsUiLessMode() &&
SupportsCompositionRestore();
}
bool SupportsCaretStateIndicator()
{
return hPipe && hPipe != INVALID_HANDLE_VALUE &&
+6
View File
@@ -53,6 +53,9 @@ bool MarkNamedpipeSessionDirtyForOwner(_In_ const void *owner);
bool EnsureNamedpipeFocusSessionActivated();
bool SupportsCharacterSetShortcut();
bool SupportsCompositionRestore();
// 候选态下的普通左右键交给 Server 决定移高亮还是移光标:Server 打开了横排左右键选词、协商过 CompositionRestore
// (光标移动要靠它的回复落地),且不是 UILess 宿主。
bool UsesHorizontalArrowSelection();
bool SupportsCaretStateIndicator();
bool SupportsTrilingualCycle();
bool SupportsInputStateShortcuts();
@@ -283,6 +286,9 @@ inline std::atomic_bool PairedPunctuationEnabled{true};
// 中文标点下出顿号的键,FanyImeEnumerationComma::kBackslash / kSlash 的组合。Server 连上之前(以及不发这个
// 消息的旧 Server)保持历史行为:只有 \。规则见 engine/contracts/enumeration_comma.h。
inline std::atomic<unsigned> EnumerationCommaKeys{FanyImeEnumerationComma::kLegacyKeys};
// 横排候选时左右方向键移动高亮(Server 发 HorizontalArrowSelectionChanged)。开着时候选态下的左右键交给 Server
// 决定移高亮还是移光标,TSF 只应用回复;Server 连上之前和旧 Server 都是关,左右键照旧本地移光标。
inline std::atomic_bool HorizontalArrowSelectionEnabled{false};
inline std::atomic_bool MicrosoftShuangpinEnabled{false};
// 双拼句中辅助码开着、勾了反引号且当前是双拼:组合中的反引号按
// engine/contracts/mid_sentence_helpcode.h 的形状规则当编码键吃掉,见 CompositionProcessorEngine_KeyClassify.cpp。
+17
View File
@@ -1366,6 +1366,23 @@ CMetasequoiaIME::KeyDownDispatchResult CMetasequoiaIME::_DispatchKeyDown(
Global::Keycode = code;
Global::wch = wch;
Global::ModifiersDown = capturedModifiers;
if (KeystrokeState.Function == FUNCTION_MOVE_LEFT_CANDIDATE ||
KeystrokeState.Function == FUNCTION_MOVE_RIGHT_CANDIDATE)
{
// The flag is what makes the Server answer with the caret or the
// highlight move. Re-check here: a reconnect or a settings change
// since classification falls back to the plain local caret move on
// both sides instead of waiting for a reply that never comes.
if (UsesHorizontalArrowSelection())
{
Global::ModifiersDown |= FanyImePipeFlags::HorizontalArrowSelection;
}
else
{
KeystrokeState.Function =
KeystrokeState.Function == FUNCTION_MOVE_LEFT_CANDIDATE ? FUNCTION_MOVE_LEFT : FUNCTION_MOVE_RIGHT;
}
}
if (KeystrokeState.Function == FUNCTION_CYCLE_INPUT_MODE)
{
// Mark the request explicitly and carry this host's own state: the
+11 -3
View File
@@ -133,7 +133,10 @@ void ApplyDeferredKeyState(DeferredShadowState &shadow, const _KEYSTROKE_STATE &
case FUNCTION_BACKSPACE_SEGMENT:
case FUNCTION_MOVE_LEFT_SEGMENT:
case FUNCTION_MOVE_RIGHT_SEGMENT:
// How much one unit covers is Server-owned, so the future raw and caret
case FUNCTION_MOVE_LEFT_CANDIDATE:
case FUNCTION_MOVE_RIGHT_CANDIDATE:
// How much one unit covers is Server-owned, as is whether a candidate
// arrow moves the highlight or the caret, so the future raw and caret
// cannot be predicted here. Invalidate the projection instead of
// advancing it by one character; the next key re-reads the real
// composition.
@@ -659,9 +662,14 @@ bool CMetasequoiaIME::_ClassifyDeferredKeyDown(_In_ ITfContext *pContext, WPARAM
case VK_ESCAPE:
return setKeyState(CATEGORY_CANDIDATE, FUNCTION_CANCEL);
case VK_LEFT:
return setKeyState(CATEGORY_COMPOSING, FUNCTION_MOVE_LEFT);
// 与同步路径一致:候选态下横排左右键选词开着时交给 Server 定。
return setKeyState(CATEGORY_COMPOSING, candidateKey && UsesHorizontalArrowSelection()
? FUNCTION_MOVE_LEFT_CANDIDATE
: FUNCTION_MOVE_LEFT);
case VK_RIGHT:
return setKeyState(CATEGORY_COMPOSING, FUNCTION_MOVE_RIGHT);
return setKeyState(CATEGORY_COMPOSING, candidateKey && UsesHorizontalArrowSelection()
? FUNCTION_MOVE_RIGHT_CANDIDATE
: FUNCTION_MOVE_RIGHT);
case VK_HOME:
return setKeyState(CATEGORY_CANDIDATE, FUNCTION_MOVE_PAGE_TOP);
case VK_END:
+17 -1
View File
@@ -1399,6 +1399,10 @@ HRESULT CMetasequoiaIME::_SetCompositionCaretSelection(TfEditCookie ec, _In_ ITf
// cannot apply that reply (UILess, an older Server without the negotiated
// capability, or a missing reply) fall back to the plain single-character move.
//
// Plain Left / Right on a horizontal candidate window (FUNCTION_MOVE_*_CANDIDATE)
// share the reply: the Server either moved the caret (CompositionRestored) or
// moved the highlight it draws itself, in which case the caret stays put.
//
//----------------------------------------------------------------------------
HRESULT CMetasequoiaIME::_HandleCompositionArrowKeySegment(TfEditCookie ec, _In_ ITfContext *pContext,
@@ -1411,8 +1415,11 @@ HRESULT CMetasequoiaIME::_HandleCompositionArrowKeySegment(TfEditCookie ec, _In_
return S_OK;
}
const bool candidateArrow =
keyFunction == FUNCTION_MOVE_LEFT_CANDIDATE || keyFunction == FUNCTION_MOVE_RIGHT_CANDIDATE;
const KEYSTROKE_FUNCTION singleStepFunction =
keyFunction == FUNCTION_MOVE_LEFT_SEGMENT ? FUNCTION_MOVE_LEFT : FUNCTION_MOVE_RIGHT;
keyFunction == FUNCTION_MOVE_LEFT_SEGMENT || keyFunction == FUNCTION_MOVE_LEFT_CANDIDATE ? FUNCTION_MOVE_LEFT
: FUNCTION_MOVE_RIGHT;
if (!Global::IsUiLessMode() && SupportsCompositionRestore() && requestId != FANY_IME_NO_REQUEST_ID)
{
@@ -1438,6 +1445,15 @@ HRESULT CMetasequoiaIME::_HandleCompositionArrowKeySegment(TfEditCookie ec, _In_
return _SetCompositionCaretSelection(ec, pContext);
}
}
if (candidateArrow)
{
// A highlight move (MoveSelection*) only needs the acknowledgement:
// the Server redraws its own candidate window. A missing reply is a
// soft miss as for Up / Down -- guessing a caret step here could put
// TSF one character away from the Server, which may have moved the
// highlight instead.
return S_OK;
}
// The Server answered with something other than the restored composition,
// or with nothing at all. This request's reply slot is already consumed
// (or was empty), so the fallback must not wait for it again.
+2
View File
@@ -132,6 +132,8 @@ HRESULT CKeyStateCategory::KeyStateHandler(KEYSTROKE_FUNCTION function, KeyHandl
case FUNCTION_MOVE_LEFT_SEGMENT:
case FUNCTION_MOVE_RIGHT_SEGMENT:
case FUNCTION_MOVE_LEFT_CANDIDATE:
case FUNCTION_MOVE_RIGHT_CANDIDATE:
return HandleKeyArrowSegment(dto);
case FUNCTION_MOVE_UP: