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
This commit is contained in:
fanlusky committed 2026-07-29 13:43:36 +08:00
1 parent e41bee9a20
commit 2edf16d01e
11 files changed
+221 -18

No files matched your search

+2
View File
@@ -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 -> 自动切换为新版引擎全拼会话
+17
View File
@@ -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;
+2
View File
@@ -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();
+97
View File
@@ -0,0 +1,97 @@
#pragma once
#include <cstddef>
#include <cstdint>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
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<std::string> ExtractHanCharacter(std::string_view text, HanCharacterEdge edge)
{
std::optional<std::string> result;
for (size_t offset = 0; offset < text.size();)
{
const size_t start = offset;
const auto lead = static_cast<unsigned char>(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<unsigned char>(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<std::wstring> &page_words,
int selected_index_in_page)
{
if (selected_index_in_page < 0 ||
static_cast<size_t>(selected_index_in_page) >= page_words.size())
{
return {};
}
return page_words[static_cast<size_t>(selected_index_in_page)];
}
} // namespace FanyImeIpc
+38 -16
View File
@@ -11,6 +11,7 @@
#include <thread>
#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<WCHAR> kCommitWithFirstCandidatePunctuation = {
static const std::unordered_set<WCHAR> 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'))
+2
View File
@@ -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
+4 -1
View File
@@ -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<std::string>(data.at("value")));
if (path == "input.wubi_schema")
return SetConfiguredWubiSchema(json::value_to<std::string>(data.at("value")));
if (path == "input.word_to_character")
return SetConfiguredWordToCharacterEnabled(json::value_to<bool>(data.at("value")));
if (path == "appearance.tsf_preedit_style")
return SetConfiguredTsfPreeditStyle(json::value_to<std::string>(data.at("value")));
if (path == "appearance.candidate_window_layout")
+10 -1
View File
@@ -1860,6 +1860,14 @@ HRESULT OnControllerCreatedSettingsWnd( //
PostSettingsConfig();
}
}
else if (path == "input.word_to_character")
{
const bool value = json::value_to<bool>(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},
+1
View File
@@ -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"
@@ -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<std::wstring> 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());
}
@@ -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);