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:
1 parent
e41bee9a20
commit
2edf16d01e
11 files changed
+221
-18
No files matched your search
@@ -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 -> 自动切换为新版引擎全拼会话
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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
|
||||
@@ -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'))
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in new issue
Block a user