From 2edf16d01e3a31b5fa5e4581f7e72ea3ba3ebacb Mon Sep 17 00:00:00 2001 From: fanlusky <1730976608@qq.com> Date: Wed, 29 Jul 2026 13:43:36 +0800 Subject: [PATCH] feat: add word-to-character candidate selection - add a disabled-by-default word-to-character setting - commit the first or last Han character with [ and ] - use the highlighted candidate for punctuation commits - preserve punctuation behavior when no Han character is available - add exact-text commit IPC support and related tests --- server/assets/config/config.toml | 2 + server/src/config/ime_config.cpp | 17 ++++ server/src/config/ime_config.h | 2 + server/src/ipc/candidate_text_policy.h | 97 +++++++++++++++++++ server/src/ipc/event_listener.cpp | 54 ++++++++--- server/src/ipc/ipc.h | 2 + server/src/settings/settings_app.cpp | 5 +- server/src/webview2/windows_webview2.cpp | 11 ++- server/tests/CMakeLists.txt | 1 + .../tests/src/test_candidate_text_policy.cpp | 46 +++++++++ .../tests/src/test_ipc_protocol_constants.cpp | 2 + 11 files changed, 221 insertions(+), 18 deletions(-) create mode 100644 server/src/ipc/candidate_text_policy.h create mode 100644 server/tests/src/test_candidate_text_policy.cpp diff --git a/server/assets/config/config.toml b/server/assets/config/config.toml index 63941ecb..efb28148 100644 --- a/server/assets/config/config.toml +++ b/server/assets/config/config.toml @@ -96,6 +96,8 @@ character_set = "simplified" # 候选输出字形,可选:"simplified", "trad default_ime_mode = "chinese" # 中英文状态范围,可选:"app"(按应用分别记忆), "global"(所有应用保持统一) ime_mode_scope = "app" +# 以词定字:开启后,[ 上屏高亮候选的首个汉字,] 上屏末个汉字 +word_to_character = false # 组合说明: # legacy + shuangpin -> 使用旧版双拼会话实现(当前最稳定) # legacy + quanpin -> 自动切换为新版引擎全拼会话 diff --git a/server/src/config/ime_config.cpp b/server/src/config/ime_config.cpp index e389b93b..c9758e2f 100644 --- a/server/src/config/ime_config.cpp +++ b/server/src/config/ime_config.cpp @@ -49,6 +49,7 @@ bool g_paging_comma_period_enabled = false; bool g_paging_tab_enabled = true; bool g_paging_page_up_down_enabled = true; bool g_candidate_arrow_navigation_enabled = true; +bool g_word_to_character_enabled = false; std::string g_candidate_window_layout = "vertical"; std::string g_candidate_window_preedit_style = "pinyin"; std::string g_theme_mode = "dark"; @@ -338,6 +339,7 @@ bool LoadImeConfig() tbl["general"]["paging_tab"].value_or(legacy_paging_mode && *legacy_paging_mode == "Shift+Tab/Tab"); g_paging_page_up_down_enabled = tbl["general"]["paging_page_up_down"].value_or(true); g_candidate_arrow_navigation_enabled = tbl["general"]["candidate_arrow_navigation"].value_or(true); + g_word_to_character_enabled = tbl["input"]["word_to_character"].value_or(false); { // Prefer explicit bool keys; fall back to legacy switch_language array. const auto legacy = tbl["keybindings"]["switch_language"].as_array(); @@ -1180,6 +1182,21 @@ bool SetConfiguredCandidateArrowNavigationEnabled(bool enabled) return true; } +bool GetConfiguredWordToCharacterEnabled() +{ + return g_word_to_character_enabled; +} + +bool SetConfiguredWordToCharacterEnabled(bool enabled) +{ + if (!WriteConfiguredValue("input", "word_to_character", enabled ? "true" : "false")) + { + return false; + } + g_word_to_character_enabled = enabled; + return true; +} + const std::string &GetConfiguredCandidateWindowLayout() { return g_candidate_window_layout; diff --git a/server/src/config/ime_config.h b/server/src/config/ime_config.h index cc7c9b05..5f0e8c11 100644 --- a/server/src/config/ime_config.h +++ b/server/src/config/ime_config.h @@ -152,6 +152,8 @@ bool GetConfiguredPagingPageUpDownEnabled(); bool SetConfiguredPagingPageUpDownEnabled(bool enabled); bool GetConfiguredCandidateArrowNavigationEnabled(); bool SetConfiguredCandidateArrowNavigationEnabled(bool enabled); +bool GetConfiguredWordToCharacterEnabled(); +bool SetConfiguredWordToCharacterEnabled(bool enabled); const std::string &GetConfiguredCandidateWindowLayout(); bool SetConfiguredCandidateWindowLayout(const std::string &layout); const std::string &GetConfiguredCandidateWindowPreeditStyle(); diff --git a/server/src/ipc/candidate_text_policy.h b/server/src/ipc/candidate_text_policy.h new file mode 100644 index 00000000..316e57e4 --- /dev/null +++ b/server/src/ipc/candidate_text_policy.h @@ -0,0 +1,97 @@ +#pragma once + +#include +#include +#include +#include +#include +#include + +namespace FanyImeIpc +{ +enum class HanCharacterEdge +{ + First, + Last, +}; + +inline bool IsHanCodePoint(char32_t code_point) +{ + return code_point == 0x3007 || + (code_point >= 0x3400 && code_point <= 0x4DBF) || + (code_point >= 0x4E00 && code_point <= 0x9FFF) || + (code_point >= 0xF900 && code_point <= 0xFAFF) || + (code_point >= 0x20000 && code_point <= 0x2FA1F) || + (code_point >= 0x30000 && code_point <= 0x323AF); +} + +inline std::optional ExtractHanCharacter(std::string_view text, HanCharacterEdge edge) +{ + std::optional result; + for (size_t offset = 0; offset < text.size();) + { + const size_t start = offset; + const auto lead = static_cast(text[offset++]); + char32_t code_point = 0; + size_t continuation_count = 0; + if (lead < 0x80) + { + code_point = lead; + } + else if ((lead & 0xE0) == 0xC0) + { + code_point = lead & 0x1F; + continuation_count = 1; + } + else if ((lead & 0xF0) == 0xE0) + { + code_point = lead & 0x0F; + continuation_count = 2; + } + else if ((lead & 0xF8) == 0xF0) + { + code_point = lead & 0x07; + continuation_count = 3; + } + else + { + return std::nullopt; + } + + if (offset + continuation_count > text.size()) + { + return std::nullopt; + } + for (size_t index = 0; index < continuation_count; ++index) + { + const auto continuation = static_cast(text[offset++]); + if ((continuation & 0xC0) != 0x80) + { + return std::nullopt; + } + code_point = (code_point << 6) | (continuation & 0x3F); + } + + if (IsHanCodePoint(code_point)) + { + result = std::string(text.substr(start, offset - start)); + if (edge == HanCharacterEdge::First) + { + return result; + } + } + } + return result; +} + +inline std::wstring HighlightedCandidateText(const std::vector &page_words, + int selected_index_in_page) +{ + if (selected_index_in_page < 0 || + static_cast(selected_index_in_page) >= page_words.size()) + { + return {}; + } + return page_words[static_cast(selected_index_in_page)]; +} +} // namespace FanyImeIpc diff --git a/server/src/ipc/event_listener.cpp b/server/src/ipc/event_listener.cpp index 0763440d..55e1c542 100644 --- a/server/src/ipc/event_listener.cpp +++ b/server/src/ipc/event_listener.cpp @@ -11,6 +11,7 @@ #include #include "Ipc.h" #include "ipc/candidate_ui_owner.h" +#include "ipc/candidate_text_policy.h" #include "ipc/focus_session_policy.h" #include "ipc/input_key_policy.h" #include "defines/defines.h" @@ -295,7 +296,7 @@ std::wstring BuildCreateWordPipePayload(const std::string &remaining_raw_input_w return remaining + L'\t' + word + L'\t' + preedit; } -bool IsCommitWithFirstCandidatePunctuationInCandidateMode(UINT keycode, WCHAR wch) +bool IsCommitWithHighlightedCandidatePunctuationInCandidateMode(UINT keycode, WCHAR wch) { if (keycode == VK_OEM_MINUS || keycode == VK_OEM_PLUS || keycode == VK_TAB) { @@ -308,7 +309,7 @@ bool IsCommitWithFirstCandidatePunctuationInCandidateMode(UINT keycode, WCHAR wc return false; } - static const std::unordered_set kCommitWithFirstCandidatePunctuation = { + static const std::unordered_set kCommitWithHighlightedCandidatePunctuation = { L'`', // L'!', // L'@', // @@ -333,7 +334,8 @@ bool IsCommitWithFirstCandidatePunctuationInCandidateMode(UINT keycode, WCHAR wc L'>', // L'?' // }; - return kCommitWithFirstCandidatePunctuation.find(wch) != kCommitWithFirstCandidatePunctuation.end(); + return kCommitWithHighlightedCandidatePunctuation.find(wch) != + kCommitWithHighlightedCandidatePunctuation.end(); } bool IsManualPinyinSeparatorKey(UINT keycode, WCHAR wch) @@ -1404,7 +1406,9 @@ bool SendCurrentDataToClient(uint64_t client_id, uint64_t activation_epoch, uint ::Global::candidate_ui.selected_text); const bool sent = SendToTsfClientViaNamedpipe(client_id, activation_epoch, msg_type, request_id, ::Global::candidate_ui.selected_text); - if (sent && msg_type == Global::DataFromServerMsgType::Normal && + if (sent && + (msg_type == Global::DataFromServerMsgType::Normal || + msg_type == Global::DataFromServerMsgType::CommitExactText) && IsPipeActivationCurrent(client_id, activation_epoch)) { ClearState(); @@ -2242,9 +2246,9 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques const bool unicode_composition_active = IsUnicodeCompositionActive(input_before_key); const bool is_paging_key = IsPagingKey(Global::Keycode); const bool is_manual_pinyin_separator = IsManualPinyinSeparatorKey(Global::Keycode, Global::Wch); - const bool is_commit_with_first_candidate_punctuation = + const bool is_commit_with_highlighted_candidate_punctuation = !is_manual_pinyin_separator && - IsCommitWithFirstCandidatePunctuationInCandidateMode(Global::Keycode, Global::Wch); + IsCommitWithHighlightedCandidatePunctuationInCandidateMode(Global::Keycode, Global::Wch); const bool is_selection_key = IsSelectionKey(Global::Keycode); const bool is_unicode_shift_digit_selection = unicode_composition_active && shift_only && Global::Keycode >= '1' && Global::Keycode <= '9'; @@ -2256,20 +2260,41 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques (Global::Keycode >= 'A' && Global::Keycode <= 'Z') || is_manual_pinyin_separator || is_unicode_hex_digit || is_unicode_plus; const bool should_forward_key_to_session = - !is_commit_with_first_candidate_punctuation && !is_selection_key && !is_paging_key && !is_composition_edit_key; + !is_commit_with_highlighted_candidate_punctuation && !is_selection_key && !is_paging_key && + !is_composition_edit_key; - // Punctuation needs a synchronous first-candidate response on the TSF pipe. + // Punctuation needs a synchronous highlighted-candidate response on the TSF pipe. // Reply before cloud-query and candidate recomputation work so the TSF-side // timeout sentinel keeps its original meaning instead of masking latency here. - if (is_commit_with_first_candidate_punctuation) + if (is_commit_with_highlighted_candidate_punctuation) { Global::MsgTypeToTsf = Global::DataFromServerMsgType::Normal; const bool has_active_composition = g_inputSession != nullptr && !g_inputSession->get_pinyin_sequence().empty(); if (has_active_composition) { EnsureCandidatePageReady(); - Global::candidate_ui.selected_text = - Global::candidate_ui.page_words.empty() ? L"" : Global::candidate_ui.page_words[0]; + auto &ui = Global::candidate_ui; + ui.selected_text = + FanyImeIpc::HighlightedCandidateText(ui.page_words, ui.selected_index_in_page); + + const bool is_word_to_character_key = + (Global::Wch == L'[' || Global::Wch == L']') && + GetConfiguredWordToCharacterEnabled(); + WordItem highlighted_item; + if (is_word_to_character_key && + ResolveCandidateItem(ui.selected_index_in_page + 1, highlighted_item)) + { + const auto edge = Global::Wch == L'[' + ? FanyImeIpc::HanCharacterEdge::First + : FanyImeIpc::HanCharacterEdge::Last; + const auto character = + FanyImeIpc::ExtractHanCharacter(CandidateTextForOutput(highlighted_item.word), edge); + if (character) + { + Global::MsgTypeToTsf = Global::DataFromServerMsgType::CommitExactText; + ui.selected_text = string_to_wstring(*character); + } + } SendCurrentDataToClient(client_id, activation_epoch, request_id); } else @@ -2363,17 +2388,14 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques } // - // 当在一些情况下,TSF 端会请求第一个候选字符串 - // - 标点,标点会和第一个候选项一起上屏 + // 在以下情况下,TSF 端会请求候选字符串 // - 空格,会上屏第一个候选项 // - 数字,会上屏相应序号对应的候选项 // - /* 1. Punctuations */ - // // 空格和数字键可能会触发造词,如果数字键上屏的汉字字符串所对应的拼音比实际的拼音要短的话, // 那么,就可能会触发造词事件,那么,就要适时改变候选框的状态 // - /* 2. VK_SPACE, 3. Digits (U-mode: Shift+1..9) */ + /* VK_SPACE, Digits (U-mode: Shift+1..9) */ if (Global::Keycode == VK_SPACE || is_unicode_shift_digit_selection || (!IsUnicodeCompositionActive(GlobalIme::composition.raw_input_with_cases) && Global::Keycode > '0' && Global::Keycode <= '9')) diff --git a/server/src/ipc/ipc.h b/server/src/ipc/ipc.h index 54847587..8dd325c0 100644 --- a/server/src/ipc/ipc.h +++ b/server/src/ipc/ipc.h @@ -311,6 +311,8 @@ constexpr UINT MoveSelectionNext = 6; constexpr UINT MovePagePrevious = 7; constexpr UINT MovePageNext = 8; constexpr UINT PipeReady = 9; +// Commit candidate_string verbatim and finish the active composition. +constexpr UINT CommitExactText = 10; } // namespace DataFromServerMsgType inline UINT MsgTypeToTsf = DataFromServerMsgType::Normal; // 默认为 Normal diff --git a/server/src/settings/settings_app.cpp b/server/src/settings/settings_app.cpp index 5c05f408..cc76be5d 100644 --- a/server/src/settings/settings_app.cpp +++ b/server/src/settings/settings_app.cpp @@ -220,7 +220,8 @@ void PostConfig() {"default_ime_mode", GetConfiguredDefaultImeMode()}, {"ime_mode_scope", GetConfiguredImeModeScope()}, {"shuangpin_schema", GetConfiguredShuangpinSchema()}, - {"wubi_schema", GetConfiguredWubiSchema()}}}, + {"wubi_schema", GetConfiguredWubiSchema()}, + {"word_to_character", GetConfiguredWordToCharacterEnabled()}}}, {"general", {{"floating_toolbar", GetConfiguredFloatingToolbarEnabled()}, {"floating_toolbar_fullwidth", toolbar.fullwidth}, @@ -324,6 +325,8 @@ bool ApplyConfigUpdate(const json::object &data) return SetConfiguredShuangpinSchema(json::value_to(data.at("value"))); if (path == "input.wubi_schema") return SetConfiguredWubiSchema(json::value_to(data.at("value"))); + if (path == "input.word_to_character") + return SetConfiguredWordToCharacterEnabled(json::value_to(data.at("value"))); if (path == "appearance.tsf_preedit_style") return SetConfiguredTsfPreeditStyle(json::value_to(data.at("value"))); if (path == "appearance.candidate_window_layout") diff --git a/server/src/webview2/windows_webview2.cpp b/server/src/webview2/windows_webview2.cpp index 5e86f66d..0d14d3d5 100644 --- a/server/src/webview2/windows_webview2.cpp +++ b/server/src/webview2/windows_webview2.cpp @@ -1860,6 +1860,14 @@ HRESULT OnControllerCreatedSettingsWnd( // PostSettingsConfig(); } } + else if (path == "input.word_to_character") + { + const bool value = json::value_to(data.at("value")); + if (SetConfiguredWordToCharacterEnabled(value)) + { + PostSettingsConfig(); + } + } else if (path.rfind("general.floating_toolbar_", 0) == 0) { const std::string item = @@ -2130,7 +2138,8 @@ void PostSettingsConfig() {"default_ime_mode", GetConfiguredDefaultImeMode()}, {"ime_mode_scope", GetConfiguredImeModeScope()}, {"shuangpin_schema", GetConfiguredShuangpinSchema()}, - {"wubi_schema", GetConfiguredWubiSchema()}}}, + {"wubi_schema", GetConfiguredWubiSchema()}, + {"word_to_character", GetConfiguredWordToCharacterEnabled()}}}, {"general", {{"floating_toolbar", GetConfiguredFloatingToolbarEnabled()}, {"floating_toolbar_fullwidth", toolbar.fullwidth}, {"floating_toolbar_punctuation", toolbar.punctuation}, diff --git a/server/tests/CMakeLists.txt b/server/tests/CMakeLists.txt index 3efef2c9..06e9529c 100644 --- a/server/tests/CMakeLists.txt +++ b/server/tests/CMakeLists.txt @@ -15,6 +15,7 @@ set(TEST_SOURCE_FILES "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_ui_owner.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_ui_action_policy.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_ui_state.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_text_policy.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_ipc_protocol_constants.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_floating_toolbar_visibility_policy.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_terminal_deactivation_policy.cpp" diff --git a/server/tests/src/test_candidate_text_policy.cpp b/server/tests/src/test_candidate_text_policy.cpp new file mode 100644 index 00000000..63445171 --- /dev/null +++ b/server/tests/src/test_candidate_text_policy.cpp @@ -0,0 +1,46 @@ +#include "ipc/candidate_text_policy.h" +#include "tests/includes/test_framework.h" + +TEST_CASE(candidate_text_policy_extracts_first_and_last_han_character) +{ + const std::string candidate = + "\xE4\xB8\xAD\xE5\x8D\x8E\xE4\xBA\xBA\xE6\xB0\x91\xE5\x85\xB1\xE5\x92\x8C\xE5\x9B\xBD"; + const auto first = FanyImeIpc::ExtractHanCharacter(candidate, FanyImeIpc::HanCharacterEdge::First); + const auto last = FanyImeIpc::ExtractHanCharacter(candidate, FanyImeIpc::HanCharacterEdge::Last); + REQUIRE(first.has_value()); + REQUIRE(last.has_value()); + REQUIRE_EQ(*first, std::string("\xE4\xB8\xAD")); + REQUIRE_EQ(*last, std::string("\xE5\x9B\xBD")); +} + +TEST_CASE(candidate_text_policy_skips_non_han_characters) +{ + const std::string candidate = "C\xE8\xAF\xAD\xE8\xA8\x80 2"; + const auto first = FanyImeIpc::ExtractHanCharacter(candidate, FanyImeIpc::HanCharacterEdge::First); + const auto last = FanyImeIpc::ExtractHanCharacter(candidate, FanyImeIpc::HanCharacterEdge::Last); + REQUIRE(first.has_value()); + REQUIRE(last.has_value()); + REQUIRE_EQ(*first, std::string("\xE8\xAF\xAD")); + REQUIRE_EQ(*last, std::string("\xE8\xA8\x80")); + REQUIRE(!FanyImeIpc::ExtractHanCharacter("GitHub", FanyImeIpc::HanCharacterEdge::First).has_value()); +} + +TEST_CASE(candidate_text_policy_supports_non_bmp_han_characters) +{ + const std::string candidate = + "\xF0\xA0\x80\x80\xE6\x96\xB9\xE6\xA1\x88\xF0\xA0\xAE\xB7"; + const auto first = FanyImeIpc::ExtractHanCharacter(candidate, FanyImeIpc::HanCharacterEdge::First); + const auto last = FanyImeIpc::ExtractHanCharacter(candidate, FanyImeIpc::HanCharacterEdge::Last); + REQUIRE(first.has_value()); + REQUIRE(last.has_value()); + REQUIRE_EQ(*first, std::string("\xF0\xA0\x80\x80")); + REQUIRE_EQ(*last, std::string("\xF0\xA0\xAE\xB7")); +} + +TEST_CASE(candidate_text_policy_uses_the_highlighted_candidate) +{ + const std::vector page_words = {L"first", L"highlighted", L"third"}; + REQUIRE_EQ(FanyImeIpc::HighlightedCandidateText(page_words, 1), std::wstring(L"highlighted")); + REQUIRE(FanyImeIpc::HighlightedCandidateText(page_words, -1).empty()); + REQUIRE(FanyImeIpc::HighlightedCandidateText(page_words, 3).empty()); +} diff --git a/server/tests/src/test_ipc_protocol_constants.cpp b/server/tests/src/test_ipc_protocol_constants.cpp index 5ff88696..fc3f16c1 100644 --- a/server/tests/src/test_ipc_protocol_constants.cpp +++ b/server/tests/src/test_ipc_protocol_constants.cpp @@ -16,6 +16,8 @@ TEST_CASE(ipc_pipe_ready_is_a_distinct_server_reply) { REQUIRE_EQ(Global::DataFromServerMsgType::PipeReady, 9u); REQUIRE(Global::DataFromServerMsgType::PipeReady > Global::DataFromServerMsgType::MovePageNext); + REQUIRE_EQ(Global::DataFromServerMsgType::CommitExactText, 10u); + REQUIRE(Global::DataFromServerMsgType::CommitExactText > Global::DataFromServerMsgType::PipeReady); REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::FocusSessionReady, 8u); REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::PipeReady, 9u); REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::InsertText, 10u);