diff --git a/engine/CMakeLists.txt b/engine/CMakeLists.txt index fbfc1236..44167508 100644 --- a/engine/CMakeLists.txt +++ b/engine/CMakeLists.txt @@ -54,6 +54,7 @@ set(METASEQUOIA_IME_ENGINE_SOURCES local_modes/local_database.cpp local_modes/quick_phrase_query.cpp local_modes/unicode_query.cpp + local_modes/v_mode_query.cpp neural/sentence_model.cpp neural/neural_decoder.cpp neural/rescore_worker.cpp diff --git a/engine/contracts/tests/windows_ipc_contract.cpp b/engine/contracts/tests/windows_ipc_contract.cpp index 3ed85208..181115b0 100644 --- a/engine/contracts/tests/windows_ipc_contract.cpp +++ b/engine/contracts/tests/windows_ipc_contract.cpp @@ -2,6 +2,7 @@ #include "../direct_helpcode.h" #include "../ipc_negotiation.h" #include "../mid_sentence_helpcode.h" +#include "../v_mode_input.h" #include "../voice_composition_pipe.h" #include @@ -140,7 +141,38 @@ int main() CHECK(FanyImeWorkerReplyType::MidSentenceHelpcodeSemicolonChanged == 29); CHECK(FanyImeWorkerReplyType::DirectHelpcodeChanged == 30); CHECK(FanyImeWorkerReplyType::MidSentenceHelpcodeUppercaseChanged == 31); - CHECK(FanyImeWorkerReplyType::MaxKnown == FanyImeWorkerReplyType::MidSentenceHelpcodeUppercaseChanged); + CHECK(FanyImeWorkerReplyType::VModeChanged == 32); + CHECK(FanyImeWorkerReplyType::MaxKnown == FanyImeWorkerReplyType::VModeChanged); + // V 模式的形状规则,TSF(WCHAR)与 Server(char)共用。 + { + using FanyImeVModeInput::Trigger; + const auto v_at = [](const std::wstring &text, std::size_t caret, wchar_t ch, Trigger trigger) { + return FanyImeVModeInput::AcceptsAt(text.data(), text.size(), caret, ch, trigger); + }; + const auto v_end = [&](const std::wstring &text, wchar_t ch, Trigger trigger) { + return v_at(text, text.size(), ch, trigger); + }; + CHECK(v_end(L"V", L'1', Trigger::UppercaseOnly) && v_end(L"V", L'(', Trigger::UppercaseOnly)); + CHECK(!v_end(L"V", L'+', Trigger::UppercaseOnly) && !v_end(L"V", L'.', Trigger::AnyCase)); + CHECK(v_end(L"V1", L'+', Trigger::UppercaseOnly) && v_end(L"V1+2", L'*', Trigger::AnyCase)); + CHECK(v_end(L"V12", L'.', Trigger::AnyCase) && v_end(L"V(1", L')', Trigger::AnyCase)); + CHECK(!v_end(L"v", L'1', Trigger::UppercaseOnly) && v_end(L"v", L'1', Trigger::AnyCase)); + CHECK(!v_end(L"V", L'1', Trigger::Off) && !v_end(L"vi", L'1', Trigger::AnyCase)); + CHECK(!v_end(L"V1", L'a', Trigger::AnyCase) && !v_end(L"T1", L'1', Trigger::AnyCase)); + // 光标在中间:不能把运算符插到 V 和第一个数字之间。 + CHECK(!v_at(L"V12", 1, L'+', Trigger::AnyCase) && v_at(L"V12", 1, L'3', Trigger::AnyCase) && + v_at(L"V12", 2, L'+', Trigger::AnyCase) && !v_at(L"V12", 0, L'3', Trigger::AnyCase)); + CHECK(!FanyImeVModeInput::IsComposition(L"V", 1, Trigger::UppercaseOnly) && + !FanyImeVModeInput::IsComposition(L"v", 1, Trigger::AnyCase) && + FanyImeVModeInput::IsComposition(L"v1", 2, Trigger::AnyCase) && + !FanyImeVModeInput::IsComposition(L"v1", 2, Trigger::UppercaseOnly) && + FanyImeVModeInput::IsComposition("V1a", 3, Trigger::UppercaseOnly) && + !FanyImeVModeInput::IsComposition("vip", 3, Trigger::AnyCase)); + CHECK(FanyImeVModeInput::TriggerFromPayload(L'2') == Trigger::AnyCase && + FanyImeVModeInput::TriggerFromPayload(L'1') == Trigger::UppercaseOnly && + FanyImeVModeInput::TriggerFromPayload(L'x') == Trigger::Off && + FanyImeVModeInput::PayloadFromTrigger(Trigger::UppercaseOnly) == L'1'); + } // 直接辅助码的 / 与 ; 形状规则,TSF(WCHAR)与引擎(char)共用。 const auto slash_at = [](const std::wstring &text, std::size_t caret) { return FanyImeDirectHelpcode::AcceptsSlashAt(text.data(), text.size(), caret); diff --git a/engine/contracts/v_mode_input.h b/engine/contracts/v_mode_input.h new file mode 100644 index 00000000..24c4d8a5 --- /dev/null +++ b/engine/contracts/v_mode_input.h @@ -0,0 +1,93 @@ +#pragma once + +#include + +// V 模式(数字转中文、算式计算)的输入形状。V 后面跟数字或左括号就进 V 模式: +// +// V123 一百二十三、壹佰贰拾叁…… +// V123.45 壹佰贰拾叁元肆角伍分…… +// V1+2*3 7 +// +// 双拼只认大写 V(小写 v 是双拼的编码键);全拼大小写都认,全拼里 v 不会出现在开头。小写 v 只有 +// 后面接了数字或左括号才算 V 模式,vip 这类输入照旧走拼音和英文混输。 +// +// 进了 V 模式后,数字和 . + - * / ( ) 都是编码键:数字不再选词,改用 Shift+数字选(同 U 模式), +// 空格上屏高亮的那个。Shift+8/9/0 打出的 * ( ) 是编码键,所以只有 Shift+1~7 选词,V 模式的候选 +// 也不会超过 7 个。 +// +// TSF 在同步吃键阶段按这里判断,Server 按同一条规则改输入串,两边必须一致,否则输入串会分叉。 +// TSF 看不到配置和方案,由 Server 用 VModeChanged 告诉它当前的 Trigger。 +namespace FanyImeVModeInput +{ +inline constexpr char kUpperPrefix = 'V'; +inline constexpr char kLowerPrefix = 'v'; + +// VModeChanged 的载荷就是这个值的十进制字符:"0" / "1" / "2"。 +enum class Trigger +{ + Off = 0, // 关着,或者当前方案不是全拼/双拼 + UppercaseOnly = 1, // 双拼:只认大写 V + AnyCase = 2, // 全拼:V 和 v 都认 +}; + +template constexpr bool IsDigit(Char ch) +{ + return ch >= Char('0') && ch <= Char('9'); +} + +// 算式里除数字以外的字符。 +template constexpr bool IsOperator(Char ch) +{ + return ch == Char('.') || ch == Char('+') || ch == Char('-') || ch == Char('*') || ch == Char('/') || + ch == Char('(') || ch == Char(')'); +} + +template constexpr bool IsInputChar(Char ch) +{ + return IsDigit(ch) || IsOperator(ch); +} + +// 紧跟在 V 后面、能开启 V 模式的字符。只认数字和左括号:小写 v 后面紧跟的 - . 等键在全拼里本来是翻页、 +// 标点,不能被抢走。 +template constexpr bool IsStartChar(Char ch) +{ + return IsDigit(ch) || ch == Char('('); +} + +template constexpr bool IsPrefix(Char ch, Trigger trigger) +{ + return (trigger != Trigger::Off && ch == Char(kUpperPrefix)) || + (trigger == Trigger::AnyCase && ch == Char(kLowerPrefix)); +} + +// text 是不是一段 V 模式输入:开头是 V(或全拼的 v),后面紧跟数字或左括号。单独一个 V / v 还不算,它照旧 +// 是普通的组合(英文、拼音的开头),接上第一个数字才进 V 模式。V 后面再混进别的键(比如字母)仍算 V 模式, +// 只是没有候选。 +template constexpr bool IsComposition(const Char *text, std::size_t size, Trigger trigger) +{ + return text != nullptr && size >= 2 && IsPrefix(text[0], trigger) && IsStartChar(text[1]); +} + +// 光标停在 text[caret] 时能否插入 ch:ch 是 V 模式的编码键,插入后整串仍是一段 V 模式输入。所以 V 后面 +// 第一个键只能是数字或左括号,也不能把运算符插到 V 和第一个数字之间。 +template +constexpr bool AcceptsAt(const Char *text, std::size_t size, std::size_t caret, Char ch, Trigger trigger) +{ + if (text == nullptr || size == 0 || caret < 1 || caret > size || !IsInputChar(ch) || !IsPrefix(text[0], trigger)) + { + return false; + } + const Char second = caret == 1 ? ch : text[1]; + return IsStartChar(second); +} + +constexpr Trigger TriggerFromPayload(wchar_t ch) +{ + return ch == L'2' ? Trigger::AnyCase : ch == L'1' ? Trigger::UppercaseOnly : Trigger::Off; +} + +constexpr wchar_t PayloadFromTrigger(Trigger trigger) +{ + return trigger == Trigger::AnyCase ? L'2' : trigger == Trigger::UppercaseOnly ? L'1' : L'0'; +} +} // namespace FanyImeVModeInput diff --git a/engine/contracts/windows_ipc.h b/engine/contracts/windows_ipc.h index 71284267..54b87c01 100644 --- a/engine/contracts/windows_ipc.h +++ b/engine/contracts/windows_ipc.h @@ -424,7 +424,11 @@ constexpr std::uint32_t DirectHelpcodeChanged = 30; // trigger checked, shuangpin, direct helpcode off). Payload "0"/"1". TSF still eats letters as before; the flag only // changes how it counts the chunk before '`' / ';' (FanyImeMidSentenceHelpcode::ChunkStart with uppercase blocks). constexpr std::uint32_t MidSentenceHelpcodeUppercaseChanged = 31; -constexpr std::uint32_t MaxKnown = MidSentenceHelpcodeUppercaseChanged; +// Which prefix opens V mode (number to Chinese, arithmetic). Payload "0" off, "1" uppercase V only (shuangpin), +// "2" V or v (quanpin); see FanyImeVModeInput::Trigger. TSF then eats digits and . + - * / ( ) after the prefix +// (FanyImeVModeInput::AcceptsAt). Single-character payload, same rule as opcode 28. +constexpr std::uint32_t VModeChanged = 32; +constexpr std::uint32_t MaxKnown = VModeChanged; // Source compatibility for the Server's historical spellings. constexpr std::uint32_t SwitchToEn = SwitchToEnglish; constexpr std::uint32_t SwitchToCn = SwitchToChinese; diff --git a/engine/local_modes/v_mode_query.cpp b/engine/local_modes/v_mode_query.cpp new file mode 100644 index 00000000..0e742beb --- /dev/null +++ b/engine/local_modes/v_mode_query.cpp @@ -0,0 +1,354 @@ +#include "v_mode_query.h" + +#include +#include +#include +#include +#include + +namespace metasequoia::local_modes +{ +namespace +{ +constexpr const char *kReadingDigits[] = {"零", "一", "二", "三", "四", "五", "六", "七", "八", "九"}; +constexpr const char *kFinancialDigits[] = {"零", "壹", "贰", "叁", "肆", "伍", "陆", "柒", "捌", "玖"}; +// 逐位读数字用 〇,和日期里的「二〇二四年」一样。 +constexpr const char *kSpelledDigits[] = {"〇", "一", "二", "三", "四", "五", "六", "七", "八", "九"}; +constexpr const char *kReadingUnits[] = {"", "十", "百", "千"}; +constexpr const char *kFinancialUnits[] = {"", "拾", "佰", "仟"}; +constexpr const char *kGroupUnits[] = {"", "万", "亿", "万亿"}; +// 万亿以上没有通用的读法,读法和大写都只到 16 位。 +constexpr std::size_t kMaxIntegerDigits = 16; +// 括号嵌套的上限,防止病态输入把递归下降打爆栈。 +constexpr int kMaxExpressionDepth = 64; + +bool all_digits(const std::string &text) +{ + return !text.empty() && + std::all_of(text.begin(), text.end(), [](unsigned char ch) { return ch >= '0' && ch <= '9'; }); +} + +std::string strip_leading_zeros(const std::string &digits) +{ + const std::size_t first = digits.find_first_not_of('0'); + return first == std::string::npos ? std::string{} : digits.substr(first); +} + +// 四位以内的一节,节内的零按「中间有零读一个零,末尾的零不读」处理。 +std::string group_text(unsigned group, const char *const *digits, const char *const *units) +{ + static constexpr unsigned kPowers[] = {1000, 100, 10, 1}; + std::string text; + bool pending_zero = false; + for (int position = 0; position < 4; ++position) + { + const unsigned digit = group / kPowers[position] % 10; + if (digit == 0) + { + pending_zero = !text.empty(); + continue; + } + if (pending_zero) + { + text += digits[0]; + pending_zero = false; + } + text += digits[digit]; + text += units[3 - position]; + } + return text; +} + +// 按四位一节从高到低拼,节与节之间:前面有数、这一节不满千或中间隔了全零的节,就补一个零。 +std::string integer_text(const std::string &digits_text, bool financial) +{ + const char *const *digits = financial ? kFinancialDigits : kReadingDigits; + const char *const *units = financial ? kFinancialUnits : kReadingUnits; + const std::string digits_only = strip_leading_zeros(digits_text); + if (digits_only.empty()) + return digits[0]; + if (digits_only.size() > kMaxIntegerDigits) + return {}; + + const std::size_t group_count = (digits_only.size() + 3) / 4; + const std::size_t head = digits_only.size() - (group_count - 1) * 4; + std::string text; + bool need_zero = false; + for (std::size_t index = 0; index < group_count; ++index) + { + const std::size_t start = index == 0 ? 0 : head + (index - 1) * 4; + const std::size_t length = index == 0 ? head : 4; + const unsigned group = static_cast(std::stoul(digits_only.substr(start, length))); + if (group == 0) + { + need_zero = true; + continue; + } + if (!text.empty() && (need_zero || group < 1000)) + text += digits[0]; + need_zero = false; + text += group_text(group, digits, units); + text += kGroupUnits[group_count - 1 - index]; + } + // 读法里打头的「一十」说成「十」:十五、十万;大写照写「壹拾」。 + if (!financial && text.rfind("一十", 0) == 0) + text.erase(0, std::strlen("一")); + return text; +} + +std::string spelled_digits(const std::string &digits, const char *const *table) +{ + std::string text; + for (const char digit : digits) + text += table[digit - '0']; + return text; +} + +std::string with_thousands_separators(const std::string &digits) +{ + std::string text; + for (std::size_t index = 0; index < digits.size(); ++index) + { + if (index > 0 && (digits.size() - index) % 3 == 0) + text += ','; + text += digits[index]; + } + return text; +} + +// 纯数字:整数,或「整数.小数」,各至少一位。 +bool split_number(const std::string &input, std::string &integer, std::string &fraction) +{ + const std::size_t dot = input.find('.'); + integer = input.substr(0, dot); + fraction = dot == std::string::npos ? std::string{} : input.substr(dot + 1); + return all_digits(integer) && (dot == std::string::npos || all_digits(fraction)); +} + +std::vector number_candidates(const std::string &integer, const std::string &fraction) +{ + std::vector texts; + const std::string reading = integer_text(integer, false); + const std::string financial = integer_text(integer, true); + if (fraction.empty()) + { + const std::string digits_only = strip_leading_zeros(integer); + texts.push_back(reading); + texts.push_back(financial); + texts.push_back(chinese_money(integer, {})); + texts.push_back(spelled_digits(integer, kSpelledDigits)); + if (digits_only.size() >= 4) + texts.push_back(with_thousands_separators(digits_only)); + } + else + { + // 带小数时多半是金额,金额排第一。 + texts.push_back(chinese_money(integer, fraction)); + if (!reading.empty()) + texts.push_back(reading + "点" + spelled_digits(fraction, kReadingDigits)); + if (!financial.empty()) + texts.push_back(financial + "点" + spelled_digits(fraction, kFinancialDigits)); + } + return texts; +} + +// + - 一层、* / 一层、一元正负号和括号,按通常的优先级和左结合求值。 +class ExpressionParser +{ + public: + explicit ExpressionParser(const std::string &text) : text_(text) + { + } + + bool parse(double &value) + { + return expression(value, 0) && position_ == text_.size(); + } + + private: + bool expression(double &value, int depth) + { + if (!term(value, depth)) + return false; + while (position_ < text_.size() && (text_[position_] == '+' || text_[position_] == '-')) + { + const char op = text_[position_++]; + double right = 0; + if (!term(right, depth)) + return false; + value = op == '+' ? value + right : value - right; + } + return true; + } + + bool term(double &value, int depth) + { + if (!factor(value, depth)) + return false; + while (position_ < text_.size() && (text_[position_] == '*' || text_[position_] == '/')) + { + const char op = text_[position_++]; + double right = 0; + if (!factor(right, depth)) + return false; + if (op == '/' && right == 0) + return false; + value = op == '*' ? value * right : value / right; + } + return true; + } + + bool factor(double &value, int depth) + { + if (depth > kMaxExpressionDepth || position_ >= text_.size()) + return false; + const char ch = text_[position_]; + if (ch == '+' || ch == '-') + { + ++position_; + if (!factor(value, depth + 1)) + return false; + value = ch == '-' ? -value : value; + return true; + } + if (ch == '(') + { + ++position_; + if (!expression(value, depth + 1) || position_ >= text_.size() || text_[position_] != ')') + return false; + ++position_; + return true; + } + return number(value); + } + + // 数字至少一位,小数点两边都要有数字:1.、.5、1.2.3 都不认。 + bool number(double &value) + { + const std::size_t start = position_; + while (position_ < text_.size() && text_[position_] >= '0' && text_[position_] <= '9') + ++position_; + if (position_ == start) + return false; + if (position_ < text_.size() && text_[position_] == '.') + { + const std::size_t fraction_start = ++position_; + while (position_ < text_.size() && text_[position_] >= '0' && text_[position_] <= '9') + ++position_; + if (position_ == fraction_start) + return false; + } + value = std::strtod(text_.substr(start, position_ - start).c_str(), nullptr); + return true; + } + + const std::string &text_; + std::size_t position_ = 0; +}; + +// 至多十位小数,去掉末尾的零:0.1+0.2 给 0.3,1/3 给 0.3333333333。太大的数用有效数字表示。 +std::string format_result(double value) +{ + if (!std::isfinite(value)) + return {}; + char buffer[64] = {}; + if (std::fabs(value) >= 1e15) + { + std::snprintf(buffer, sizeof(buffer), "%.15g", value); + return buffer; + } + std::snprintf(buffer, sizeof(buffer), "%.10f", value); + std::string text = buffer; + text.erase(text.find_last_not_of('0') + 1); + if (!text.empty() && text.back() == '.') + text.pop_back(); + return text == "-0" ? "0" : text; +} + +bool has_operator(const std::string &input) +{ + return input.find_first_of("+-*/()") != std::string::npos; +} +} // namespace + +std::string chinese_number_reading(const std::string &digits) +{ + return all_digits(digits) ? integer_text(digits, false) : std::string{}; +} + +std::string chinese_financial_number(const std::string &digits) +{ + return all_digits(digits) ? integer_text(digits, true) : std::string{}; +} + +std::string chinese_money(const std::string &integer_digits, const std::string &fraction_digits) +{ + if (!all_digits(integer_digits) || fraction_digits.size() > 2 || + (!fraction_digits.empty() && !all_digits(fraction_digits))) + return {}; + const std::string integer = integer_text(integer_digits, true); + if (integer.empty()) + return {}; + const bool integer_zero = strip_leading_zeros(integer_digits).empty(); + const unsigned jiao = fraction_digits.size() >= 1 ? static_cast(fraction_digits[0] - '0') : 0; + const unsigned fen = fraction_digits.size() >= 2 ? static_cast(fraction_digits[1] - '0') : 0; + + std::string text = integer_zero ? std::string{} : integer + "元"; + if (jiao == 0 && fen == 0) + return integer_zero ? std::string(kFinancialDigits[0]) + "元整" : text + "整"; + if (jiao > 0) + text += std::string(kFinancialDigits[jiao]) + "角"; + else if (!integer_zero) + text += kFinancialDigits[0]; // 壹佰元零伍分 + if (fen > 0) + text += std::string(kFinancialDigits[fen]) + "分"; + return text; +} + +bool evaluate_v_mode_expression(const std::string &expression, std::string &result) +{ + double value = 0; + if (expression.empty() || !ExpressionParser(expression).parse(value)) + return false; + result = format_result(value); + return !result.empty(); +} + +std::vector query_v_mode(const std::string &input, int limit) +{ + if (limit <= 0 || input.empty()) + return {}; + + std::vector texts; + std::string integer; + std::string fraction; + if (!has_operator(input) && split_number(input, integer, fraction)) + { + texts = number_candidates(integer, fraction); + } + else if (has_operator(input)) + { + std::string result; + if (evaluate_v_mode_expression(input, result)) + { + texts.push_back(result); + texts.push_back(input + "=" + result); + } + } + + texts.erase(std::remove(texts.begin(), texts.end(), std::string{}), texts.end()); + std::vector results; + for (std::string &text : texts) + { + if (std::find_if(results.begin(), results.end(), [&](const WordItem &item) { return item.word == text; }) != + results.end()) + continue; + if (static_cast(results.size()) >= limit) + break; + results.emplace_back("", std::move(text), 0, CandidateSource::Generated); + } + for (std::size_t index = 0; index < results.size(); ++index) + results[index].weight = static_cast(results.size() - index); + return results; +} +} // namespace metasequoia::local_modes diff --git a/engine/local_modes/v_mode_query.h b/engine/local_modes/v_mode_query.h new file mode 100644 index 00000000..afea09ef --- /dev/null +++ b/engine/local_modes/v_mode_query.h @@ -0,0 +1,32 @@ +#pragma once + +#include "../core/word_item.h" + +#include +#include + +namespace metasequoia::local_modes +{ +// V 模式的候选,input 是 V(或全拼的 v)后面那一串,形状见 contracts/v_mode_input.h。 +// +// 纯数字转中文: +// 123 一百二十三、壹佰贰拾叁、壹佰贰拾叁元整、一二三 +// 12345 再加一条千分位 12,345 +// 123.45 壹佰贰拾叁元肆角伍分、一百二十三点四五、壹佰贰拾叁点肆伍(超过两位小数不出金额) +// 整数部分超过 16 位(万亿以上)时不出读法,只留逐位的那条。 +// +// 含运算符的是算式,支持 + - * / 和括号、一元负号: +// 1+2*3 7、1+2*3=7 +// 除以零、写错或没写完时没有候选。 +std::vector query_v_mode(const std::string &input, int limit = 10); + +// 下面几个单独给出来供测试和别处复用。整数部分是十进制数字串,可以带前导零。 +// 读法:一百二十三、十五、一万零一十;超过 16 位返回空串。 +std::string chinese_number_reading(const std::string &digits); +// 大写:壹佰贰拾叁、壹拾伍;超过 16 位返回空串。 +std::string chinese_financial_number(const std::string &digits); +// 金额大写:整数部分加至多两位小数,壹佰贰拾叁元肆角伍分;超出范围返回空串。 +std::string chinese_money(const std::string &integer_digits, const std::string &fraction_digits); +// 算式求值的结果文本,不是合法算式时返回 false。 +bool evaluate_v_mode_expression(const std::string &expression, std::string &result); +} // namespace metasequoia::local_modes diff --git a/engine/tests/src/test_input_session.cpp b/engine/tests/src/test_input_session.cpp index 6ed6fe00..ac9e1417 100644 --- a/engine/tests/src/test_input_session.cpp +++ b/engine/tests/src/test_input_session.cpp @@ -2403,6 +2403,19 @@ int run_test() require(date_time_session.handle_command(metasequoia::Command::Cancel).handled, "Cancel did not leave the specific time."); + // V 模式由 Server 按原串识别,引擎照常收到整串:运算符和括号混在拼音原串里也不能出错。 + for (const SchemeType scheme : {SchemeType::Quanpin, SchemeType::Shuangpin}) + { + metasequoia::InputSession v_mode_raw(scheme); + for (const char *raw : {"V1+2*(3-4)/5.6", "v12.5", "V(", "V9)", "v1//2"}) + { + v_mode_raw.set_pinyin_sequence(raw); + v_mode_raw.set_pinyin_sequence_with_cases(raw); + v_mode_raw.recompute_candidates(); + (void)v_mode_raw.get_candidates(); + } + } + metasequoia::LocalModeOptions disabled_date_time_options; disabled_date_time_options.date_time = false; metasequoia::InputSession disabled_date_time(SchemeType::Quanpin); diff --git a/engine/tests/src/test_local_modes.cpp b/engine/tests/src/test_local_modes.cpp index 3cf30ffc..f1ec8743 100644 --- a/engine/tests/src/test_local_modes.cpp +++ b/engine/tests/src/test_local_modes.cpp @@ -2,6 +2,7 @@ #include "../../local_modes/emoji_query.h" #include "../../local_modes/kaomoji_query.h" #include "../../local_modes/quick_phrase_query.h" +#include "../../local_modes/v_mode_query.h" #include "../../core/data_path.h" #include @@ -213,6 +214,71 @@ int run_test() metasequoia::local_modes::date_time_category("9:05") == "time", "Specific date/time recognition or grouping was incorrect."); + // V 模式:数字转中文。 + const std::array expected_v_integer = {"一百二十三", "壹佰贰拾叁", "壹佰贰拾叁元整", "一二三"}; + require_words(metasequoia::local_modes::query_v_mode("123"), expected_v_integer, + "V mode did not convert an integer to Chinese."); + const std::array expected_v_money = {"壹佰贰拾叁元肆角伍分", "一百二十三点四五", + "壹佰贰拾叁点肆伍"}; + require_words(metasequoia::local_modes::query_v_mode("123.45"), expected_v_money, + "V mode did not put the money form first for a decimal."); + const std::array expected_v_thousands = {"一万二千三百四十五", "壹万贰仟叁佰肆拾伍", + "壹万贰仟叁佰肆拾伍元整", "一二三四五", "12,345"}; + require_words(metasequoia::local_modes::query_v_mode("12345"), expected_v_thousands, + "V mode did not add the thousands-separated form."); + using metasequoia::local_modes::chinese_financial_number; + using metasequoia::local_modes::chinese_money; + using metasequoia::local_modes::chinese_number_reading; + const std::array, 12> readings = {{ + {"0", "零"}, + {"10", "十"}, + {"15", "十五"}, + {"110", "一百一十"}, + {"1001", "一千零一"}, + {"10010", "一万零一十"}, + {"100000", "十万"}, + {"20000", "二万"}, + {"100000001", "一亿零一"}, + {"1000010000", "十亿零一万"}, + {"007", "七"}, + {"1234567890123456", "一千二百三十四万亿五千六百七十八亿九千零一十二万三千四百五十六"}, + }}; + for (const auto &reading : readings) + require(chinese_number_reading(reading[0]) == reading[1], "A Chinese number reading was wrong."); + require(chinese_number_reading("12345678901234567").empty() && chinese_financial_number("10") == "壹拾" && + chinese_financial_number("10010") == "壹万零壹拾", + "Chinese financial numbers or the reading limit were wrong."); + require(chinese_money("100", "05") == "壹佰元零伍分" && chinese_money("0", "45") == "肆角伍分" && + chinese_money("0", "05") == "伍分" && chinese_money("123", "4") == "壹佰贰拾叁元肆角" && + chinese_money("0", "") == "零元整" && chinese_money("1", "456").empty(), + "Chinese money amounts were wrong."); + + // V 模式:算式计算。 + const std::array expected_v_expression = {"7", "1+2*3=7"}; + require_words(metasequoia::local_modes::query_v_mode("1+2*3"), expected_v_expression, + "V mode did not evaluate an arithmetic expression."); + const std::array, 7> expressions = {{ + {"(1+2)*3", "9"}, + {"10/4", "2.5"}, + {"0.1+0.2", "0.3"}, + {"1/3", "0.3333333333"}, + {"2*-3", "-6"}, + {"-(2-5)", "3"}, + {"7-7", "0"}, + }}; + for (const auto &expression : expressions) + { + std::string result; + require(metasequoia::local_modes::evaluate_v_mode_expression(expression[0], result) && result == expression[1], + "An arithmetic expression evaluated to the wrong result."); + } + for (const char *input : + std::array{"1/0", "1+", "(1+2", "1+2)", "1..2", "1.", "1.2.3", "12a", "2(3)"}) + { + require(metasequoia::local_modes::query_v_mode(input).empty(), + "An incomplete or invalid V-mode input produced candidates."); + } + const auto suffix = std::to_string(std::chrono::high_resolution_clock::now().time_since_epoch().count()); const std::filesystem::path quick_phrase_directory = std::filesystem::temp_directory_path() / ("metasequoia-quick-phrase-" + suffix); diff --git a/installer/default_config/config.default.toml b/installer/default_config/config.default.toml index 51ffbc7d..f75ae7c4 100644 --- a/installer/default_config/config.default.toml +++ b/installer/default_config/config.default.toml @@ -473,6 +473,7 @@ kaomoji_mode = true # Shift+M 进入颜文字快捷输入模式(支持全拼/ jianpin_mode = true # Shift+J 进入超级简拼模式(每个字母作为简拼;双拼按方案转换 zh/ch/sh) y_mode = true # Shift+Y 进入临时英文模式(中文状态下直接打英文,上屏后回到中文) r_mode = true # Shift+R 进入临时日语模式(中文状态下使用罗马字输入日语,上屏后回到中文) +v_mode = true # V 模式:V 后接数字转中文(V123 → 一百二十三,V123.45 → 壹佰贰拾叁元肆角伍分)或计算算式(V1+2*3 → 7);双拼用大写 V,全拼大小写均可 # # AI Assistant diff --git a/server/assets/config/config.toml b/server/assets/config/config.toml index b75db5ff..0d56ce81 100644 --- a/server/assets/config/config.toml +++ b/server/assets/config/config.toml @@ -471,6 +471,7 @@ kaomoji_mode = true # Shift+M 进入颜文字快捷输入模式(支持全拼/ jianpin_mode = true # Shift+J 进入超级简拼模式(每个字母作为简拼;双拼按方案转换 zh/ch/sh) y_mode = true # Shift+Y 进入临时英文模式(中文状态下直接打英文,上屏后回到中文) r_mode = true # Shift+R 进入临时日语模式(中文状态下使用罗马字输入日语,上屏后回到中文) +v_mode = true # V 模式:V 后接数字转中文(V123 → 一百二十三,V123.45 → 壹佰贰拾叁元肆角伍分)或计算算式(V1+2*3 → 7);双拼用大写 V,全拼大小写均可 # # AI Assistant diff --git a/server/src/config/ime_config.cpp b/server/src/config/ime_config.cpp index 6a1ea985..08ddbbce 100644 --- a/server/src/config/ime_config.cpp +++ b/server/src/config/ime_config.cpp @@ -147,6 +147,7 @@ bool g_kaomoji_mode_enabled = true; bool g_jianpin_mode_enabled = true; bool g_y_mode_enabled = true; bool g_r_mode_enabled = true; +bool g_v_mode_enabled = true; bool g_clipboard_history_enabled = false; bool g_paging_minus_equal_enabled = true; bool g_paging_comma_period_enabled = false; @@ -538,6 +539,7 @@ bool LoadImeConfig() g_jianpin_mode_enabled = tbl["utility"]["jianpin_mode"].value_or(true); g_y_mode_enabled = tbl["utility"]["y_mode"].value_or(true); g_r_mode_enabled = tbl["utility"]["r_mode"].value_or(true); + g_v_mode_enabled = tbl["utility"]["v_mode"].value_or(true); { const bool previous_clipboard_history = g_clipboard_history_enabled; static bool clipboard_history_loaded = false; diff --git a/server/src/config/ime_config.h b/server/src/config/ime_config.h index eccdce45..778c1724 100644 --- a/server/src/config/ime_config.h +++ b/server/src/config/ime_config.h @@ -440,6 +440,12 @@ bool GetConfiguredYModeEnabled(); bool SetConfiguredYModeEnabled(bool enabled); bool GetConfiguredRModeEnabled(); bool SetConfiguredRModeEnabled(bool enabled); +// V 模式:数字转中文、算式计算。双拼按大写 V 触发,全拼大小写 V 都可以,规则见 +// engine/contracts/v_mode_input.h。 +bool GetConfiguredVModeEnabled(); +bool SetConfiguredVModeEnabled(bool enabled); +// VModeChanged 的载荷:"0" 关着或不是全拼/双拼,"1" 双拼(只认大写 V),"2" 全拼(V、v 都认)。 +std::wstring FormatVModeWorkerPayload(); bool GetConfiguredClipboardHistoryEnabled(); bool SetConfiguredClipboardHistoryEnabled(bool enabled); bool GetConfiguredPagingMinusEqualEnabled(); diff --git a/server/src/config/ime_config_general.cpp b/server/src/config/ime_config_general.cpp index 0e42288a..46a9310b 100644 --- a/server/src/config/ime_config_general.cpp +++ b/server/src/config/ime_config_general.cpp @@ -3,6 +3,7 @@ #include #include #include "clipboard/clipboard_history.h" +#include "engine/contracts/v_mode_input.h" #include "statistics/stats_store.h" using namespace ime_config_detail; @@ -477,6 +478,33 @@ bool SetConfiguredRModeEnabled(bool enabled) return true; } +bool GetConfiguredVModeEnabled() +{ + return g_v_mode_enabled; +} + +bool SetConfiguredVModeEnabled(bool enabled) +{ + if (!WriteConfiguredValue("utility", "v_mode", enabled ? "true" : "false")) + { + return false; + } + g_v_mode_enabled = enabled; + return true; +} + +std::wstring FormatVModeWorkerPayload() +{ + using FanyImeVModeInput::Trigger; + // 日语模式下当前方案是 JapaneseRomaji,自然落到 Off。 + const SchemeType scheme = GetConfiguredActiveInputScheme(); + const Trigger trigger = !g_v_mode_enabled ? Trigger::Off + : scheme == SchemeType::Quanpin ? Trigger::AnyCase + : scheme == SchemeType::Shuangpin ? Trigger::UppercaseOnly + : Trigger::Off; + return std::wstring(1, FanyImeVModeInput::PayloadFromTrigger(trigger)); +} + bool GetConfiguredClipboardHistoryEnabled() { return g_clipboard_history_enabled; diff --git a/server/src/config/ime_config_internal.h b/server/src/config/ime_config_internal.h index 712d5e37..94f153d9 100644 --- a/server/src/config/ime_config_internal.h +++ b/server/src/config/ime_config_internal.h @@ -188,6 +188,7 @@ extern bool g_kaomoji_mode_enabled; extern bool g_jianpin_mode_enabled; extern bool g_y_mode_enabled; extern bool g_r_mode_enabled; +extern bool g_v_mode_enabled; extern bool g_clipboard_history_enabled; extern bool g_paging_minus_equal_enabled; extern bool g_paging_comma_period_enabled; diff --git a/server/src/ipc/event_listener.cpp b/server/src/ipc/event_listener.cpp index b2e074a9..54db2dba 100644 --- a/server/src/ipc/event_listener.cpp +++ b/server/src/ipc/event_listener.cpp @@ -29,6 +29,7 @@ #include "english/english_ime.h" #include "config/ime_config.h" #include "engine/contracts/date_time_input.h" +#include "engine/contracts/v_mode_input.h" #include "conversion/chinese_converter.h" #include "log/candidate_diag_log.h" #include "ipc/event_listener_internal.h" @@ -273,6 +274,18 @@ bool IsYModeInput(const std::string &raw) return IsYModeCompositionActive(raw) && raw.size() > 1; } +FanyImeVModeInput::Trigger CurrentVModeTrigger() +{ + return FanyImeVModeInput::TriggerFromPayload(FormatVModeWorkerPayload().front()); +} + +// V 模式没有单独的触发标记:V(全拼还有 v)后面紧跟数字或左括号就是,和 TSF 用同一条规则 +// (engine/contracts/v_mode_input.h),所以小写 v 开头的 vip 仍是普通输入。 +bool IsVModeCompositionActive(const std::string &raw) +{ + return FanyImeVModeInput::IsComposition(raw.data(), raw.size(), CurrentVModeTrigger()); +} + void ClearSpecialModeTriggers() { g_quick_phrase_triggered = false; @@ -292,7 +305,7 @@ bool IsSpecialModeCompositionActive(const std::string &raw) { return IsQuickPhraseCompositionActive(raw) || IsUnicodeCompositionActive(raw) || IsDateTimeCompositionActive(raw) || IsEmojiCompositionActive(raw) || IsKaomojiCompositionActive(raw) || IsJianpinCompositionActive(raw) || - IsYModeCompositionActive(raw); + IsYModeCompositionActive(raw) || IsVModeCompositionActive(raw); } } // namespace event_listener_detail diff --git a/server/src/ipc/event_listener_candidates.cpp b/server/src/ipc/event_listener_candidates.cpp index 7158fa8b..aed23969 100644 --- a/server/src/ipc/event_listener_candidates.cpp +++ b/server/src/ipc/event_listener_candidates.cpp @@ -28,6 +28,7 @@ #include "engine/local_modes/emoji_query.h" #include "engine/local_modes/kaomoji_query.h" #include "engine/local_modes/jianpin_query.h" +#include "engine/local_modes/v_mode_query.h" #include "engine/shuangpin/shuangpin_profile.h" #include "mixed/date_time_candidates.h" #include "log/candidate_diag_log.h" @@ -694,6 +695,11 @@ void PrepareCandidateList(uint64_t client_id, uint64_t activation_epoch) // Show the typed English immediately; dictionary completions arrive asynchronously. items.emplace_back("", current_input.substr(1), 0, CandidateSource::Generated); } + else if (!g_english_input_mode && IsVModeCompositionActive(current_input)) + { + // V 模式:数字转中文或算式结果。候选来源是 Generated,选中时直接上屏,不进词库、不调频。 + items = metasequoia::local_modes::query_v_mode(current_input.substr(1)); + } else if (IsSpecialModeCompositionActive(current_input)) { // A K/U/T/E/M/J/Y special-mode prefix that is not yet a complete input (e.g. diff --git a/server/src/ipc/event_listener_internal.h b/server/src/ipc/event_listener_internal.h index 585062c9..6287ec1c 100644 --- a/server/src/ipc/event_listener_internal.h +++ b/server/src/ipc/event_listener_internal.h @@ -17,6 +17,7 @@ #include "ipc/candidate_selection_policy.h" #include "ipc/candidate_ui_owner.h" #include "ipc/event_listener.h" +#include "engine/contracts/v_mode_input.h" #include "window/caret_state_indicator_policy.h" // IPC logging is compiled out. The macros still have to *mention* their arguments, otherwise every @@ -75,6 +76,9 @@ bool IsKaomojiCompositionActive(const std::string &raw); bool IsJianpinCompositionActive(const std::string &raw); bool IsYModeCompositionActive(const std::string &raw); bool IsYModeInput(const std::string &raw); +// 当前方案下哪个前缀开启 V 模式,和发给 TSF 的 VModeChanged 是同一个值。 +FanyImeVModeInput::Trigger CurrentVModeTrigger(); +bool IsVModeCompositionActive(const std::string &raw); void ClearSpecialModeTriggers(); bool IsSpecialModeCompositionActive(const std::string &raw); diff --git a/server/src/ipc/event_listener_keys.cpp b/server/src/ipc/event_listener_keys.cpp index 6e82eb29..ad93c112 100644 --- a/server/src/ipc/event_listener_keys.cpp +++ b/server/src/ipc/event_listener_keys.cpp @@ -175,6 +175,24 @@ bool IsDateTimeInputKey(UINT keycode, WCHAR wch, const std::string &raw_input) return FanyImeDateTimeInput::AcceptsAt(raw_input.data(), raw_input.size(), caret, static_cast(wch)); } +// V 模式的数字和 . + - * / ( ):插入后仍是一段 V 模式输入时是编码键,不再选词、翻页或当标点。只看字符不看 +// 键位,主键盘和小键盘一样;Shift+1 这类出 ! 的仍是选词键。TSF 按同一条规则 +// (FanyImeVModeInput::AcceptsAt)和同一个 Trigger(VModeChanged)预判吃键。光标按 IsDirectHelpcodeSlashKey +// 同样的方式算。 +bool IsVModeInputKey(WCHAR wch, const std::string &raw_input) +{ + if (wch > 0x7f || g_inputSession == nullptr) + { + return false; + } + const auto &composition = GlobalIme::composition; + const size_t caret = composition.raw_input_with_cases != raw_input && composition.caret_position == 0 + ? raw_input.size() + : (std::min)(composition.caret_position, raw_input.size()); + return FanyImeVModeInput::AcceptsAt(raw_input.data(), raw_input.size(), caret, static_cast(wch), + CurrentVModeTrigger()); +} + bool IsMicrosoftShuangpinIngKey(UINT keycode, WCHAR wch, const std::string &raw_input) { return IsMicrosoftShuangpinIngKeyAt(keycode, wch, raw_input, @@ -219,8 +237,8 @@ bool IsSelectionKey(UINT keycode) const bool shift_only = (Global::ModifiersDown & 0b00000111u) == 0b00000001u; return shift_only && keycode >= '1' && keycode <= '9'; } - // T 模式指定日期时间:接得上的数字是编码键。 - return !IsDateTimeInputKey(keycode, Global::Wch, raw); + // T 模式指定日期时间:接得上的数字是编码键;V 模式里的数字也是。 + return !IsDateTimeInputKey(keycode, Global::Wch, raw) && !IsVModeInputKey(Global::Wch, raw); } return false; } @@ -437,7 +455,7 @@ bool ApplyCompositionEditKey(UINT keycode, WCHAR wch, UINT modifiers_down, bool { input = '-'; } - else if (IsDateTimeInputKey(keycode, wch, raw)) + else if (IsDateTimeInputKey(keycode, wch, raw) || IsVModeInputKey(wch, raw)) { input = static_cast(wch); } @@ -846,7 +864,9 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques if (Global::Keycode == VK_RETURN && !input_before_key.empty() && GetConfiguredEnterLearnsEnglishWord()) { std::string english_word; - const bool shift_letter_special_mode = IsShiftLetterSpecialModeTriggered(); + // V 模式没有触发标记,按输入串认:回车上屏的 V123 不是要学的英文词。 + const bool shift_letter_special_mode = + IsShiftLetterSpecialModeTriggered() || IsVModeCompositionActive(input_before_key); if (FanyImeIpc::ShouldLearnEnteredEnglishWord(g_english_input_mode, shift_letter_special_mode, chinese_scheme, g_inputSession->is_all_complete_pure_pinyin())) english_word = g_r_mode_triggered ? "R" + input_before_key : input_before_key; @@ -909,16 +929,23 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques IsMidSentenceHelpcodeMarkerKey(Global::Keycode, Global::Wch, input_before_key); const bool is_direct_helpcode_slash = IsDirectHelpcodeSlashKey(Global::Keycode, Global::Wch, input_before_key); const bool is_date_time_input_key = IsDateTimeInputKey(Global::Keycode, Global::Wch, input_before_key); + // V 模式的数字和 . + - * / ( ):只进输入串,不选词、不翻页、不当标点。 + const bool is_v_mode_input_key = IsVModeInputKey(Global::Wch, input_before_key); + // V 模式里数字键用来输入,Shift+数字选词(同 U 模式)。Shift+1 出的是 !,不排除就会被当成「上屏高亮候选 + // + 标点」;Shift+8/9/0 出 * ( ) 是编码键,上一行已经收下。 + const bool is_v_mode_shift_digit_selection = !is_v_mode_input_key && shift_only && Global::Keycode >= '1' && + Global::Keycode <= '9' && IsVModeCompositionActive(input_before_key); // 日语模式下 '-' 是长音符输入键,既不翻页也不做词转字。 const bool is_japanese_long_vowel = IsJapaneseLongVowelKey(Global::Keycode, Global::Wch); - const int word_character_direction = - FanyImeIpc::WordToCharacterDirection(Global::Keycode, Global::Wch, Global::ModifiersDown, - GetConfiguredWordToCharacterEnabled() && !is_japanese_long_vowel, - GetConfiguredWordToCharacterKeys() == "minus_equal"); + const int word_character_direction = FanyImeIpc::WordToCharacterDirection( + Global::Keycode, Global::Wch, Global::ModifiersDown, + GetConfiguredWordToCharacterEnabled() && !is_japanese_long_vowel && !is_v_mode_input_key, + GetConfiguredWordToCharacterKeys() == "minus_equal"); const bool is_commit_with_highlighted_candidate_punctuation = word_character_direction != 0 || (!is_manual_pinyin_separator && !is_microsoft_shuangpin_ing_key && !is_mid_sentence_helpcode_marker && - !is_direct_helpcode_slash && !is_date_time_input_key && + !is_direct_helpcode_slash && !is_date_time_input_key && !is_v_mode_input_key && + !is_v_mode_shift_digit_selection && IsCommitWithHighlightedCandidatePunctuationInCandidateMode(Global::Keycode, Global::Wch)); const bool is_selection_key = IsSelectionKey(Global::Keycode); const bool is_unicode_shift_digit_selection = @@ -931,7 +958,7 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques 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_unicode_hex_digit || is_unicode_plus || is_japanese_long_vowel || - is_date_time_input_key; + 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; @@ -1061,6 +1088,11 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques // Keep preedit identical to the typed Y-prefixed English. GlobalIme::composition.segmented_pinyin = GlobalIme::composition.raw_input_with_cases; } + if (!g_english_input_mode && IsVModeCompositionActive(GlobalIme::composition.raw_input_with_cases)) + { + // Keep preedit identical to the typed V-prefixed number or expression. + GlobalIme::composition.segmented_pinyin = GlobalIme::composition.raw_input_with_cases; + } SyncShuangpinPreeditForms(); // 五笔四码唯一自动上屏:敲满四码且码表只给一个候选时,直接走与空格完全相同的提交路径, @@ -1222,7 +1254,7 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques if (FanyImeIpc::ShouldSendCompositionReply(Global::Keycode >= 'A' && Global::Keycode <= 'Z', is_manual_pinyin_separator, is_microsoft_shuangpin_ing_key, is_unicode_hex_digit, is_unicode_plus, is_japanese_long_vowel, - is_date_time_input_key)) + is_date_time_input_key || is_v_mode_input_key)) { if (IsUiLessMode()) { @@ -1316,7 +1348,7 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques /* VK_SPACE, Digits (U-mode: Shift+1..9; T-mode digits that extend a date/time are input) */ if (Global::Keycode == VK_SPACE || is_unicode_shift_digit_selection || (!IsUnicodeCompositionActive(GlobalIme::composition.raw_input_with_cases) && !is_date_time_input_key && - Global::Keycode > '0' && Global::Keycode <= '9')) + !is_v_mode_input_key && Global::Keycode > '0' && Global::Keycode <= '9')) { ProcessSelectionKey(Global::Keycode, client_id, activation_epoch); SendCurrentDataToClient(client_id, activation_epoch, request_id); @@ -1357,7 +1389,7 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques } } } - else if (IsCandidateNavigationKey(Global::Keycode) && !is_unicode_plus) + else if (IsCandidateNavigationKey(Global::Keycode) && !is_unicode_plus && !is_v_mode_input_key) { auto &ui = Global::candidate_ui; UINT result = Global::DataFromServerMsgType::NavigationIgnored; diff --git a/server/src/ipc/event_listener_pipes.cpp b/server/src/ipc/event_listener_pipes.cpp index 3d6f6607..dec58efd 100644 --- a/server/src/ipc/event_listener_pipes.cpp +++ b/server/src/ipc/event_listener_pipes.cpp @@ -848,6 +848,9 @@ void RegisteredPipeMonitorThread(HANDLE clientPipe, UINT pipeRole, uint64_t hand SendToTsfWorkerThreadClientViaNamedpipe( hello.client_id, Global::DataFromServerMsgTypeToTsfWorkerThread::DirectHelpcodeChanged, FormatDirectHelpcodeWorkerPayload()); + SendToTsfWorkerThreadClientViaNamedpipe(hello.client_id, + Global::DataFromServerMsgTypeToTsfWorkerThread::VModeChanged, + FormatVModeWorkerPayload()); SendToTsfWorkerThreadClientViaNamedpipe( hello.client_id, Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeUppercaseChanged, FormatMidSentenceHelpcodeUppercaseWorkerPayload()); diff --git a/server/src/settings/settings_app.cpp b/server/src/settings/settings_app.cpp index 59dcd862..f478d7de 100644 --- a/server/src/settings/settings_app.cpp +++ b/server/src/settings/settings_app.cpp @@ -554,6 +554,7 @@ std::wstring BuildConfigMessage(bool refresh_skin_catalog) {"jianpin_mode", GetConfiguredJianpinModeEnabled()}, {"y_mode", GetConfiguredYModeEnabled()}, {"r_mode", GetConfiguredRModeEnabled()}, + {"v_mode", GetConfiguredVModeEnabled()}, {"clipboard_history", GetConfiguredClipboardHistoryEnabled()}}}, {"appearance", {{"ui_backend", GetConfiguredUiBackend()}, @@ -942,6 +943,8 @@ bool ApplyConfigUpdate(const json::object &data) return SetConfiguredYModeEnabled(json::value_to(data.at("value"))); if (path == "utility.r_mode") return SetConfiguredRModeEnabled(json::value_to(data.at("value"))); + if (path == "utility.v_mode") + return SetConfiguredVModeEnabled(json::value_to(data.at("value"))); if (path == "utility.clipboard_history") return SetConfiguredClipboardHistoryEnabled(json::value_to(data.at("value"))); if (path == "general.paging_minus_equal") diff --git a/server/src/webview2/windows_webview2_settings.cpp b/server/src/webview2/windows_webview2_settings.cpp index 1095cd9b..94bca62a 100644 --- a/server/src/webview2/windows_webview2_settings.cpp +++ b/server/src/webview2/windows_webview2_settings.cpp @@ -1007,6 +1007,16 @@ static void ApplyUtilitySubkey(const std::string &path, const json::object &data PostSettingsConfig(); } } + if (path == "utility.v_mode") + { + const bool value = json::value_to(data.at("value")); + if (SetConfiguredVModeEnabled(value)) + { + BroadcastToTsfWorkerThreadViaNamedpipe(Global::DataFromServerMsgTypeToTsfWorkerThread::VModeChanged, + FormatVModeWorkerPayload()); + PostSettingsConfig(); + } + } if (path == "utility.clipboard_history") { const bool value = json::value_to(data.at("value")); @@ -1691,7 +1701,8 @@ void PostSettingsConfig() {"kaomoji_mode", GetConfiguredKaomojiModeEnabled()}, {"jianpin_mode", GetConfiguredJianpinModeEnabled()}, {"y_mode", GetConfiguredYModeEnabled()}, - {"r_mode", GetConfiguredRModeEnabled()}}}, + {"r_mode", GetConfiguredRModeEnabled()}, + {"v_mode", GetConfiguredVModeEnabled()}}}, {"appearance", {{"ui_backend", GetConfiguredUiBackend()}, {"settings_window_linger", GetConfiguredSettingsWindowLinger()}, diff --git a/server/src/window/ime_windows.cpp b/server/src/window/ime_windows.cpp index b1d3415a..babdefcb 100644 --- a/server/src/window/ime_windows.cpp +++ b/server/src/window/ime_windows.cpp @@ -276,6 +276,9 @@ void ApplyConfiguredInputScheme() BroadcastToTsfWorkerThreadViaNamedpipe( Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeUppercaseChanged, FormatMidSentenceHelpcodeUppercaseWorkerPayload()); + // V 模式只在全拼/双拼下有效,全拼还认小写 v,换方案或切日语模式都要让 TSF 重新判断。 + BroadcastToTsfWorkerThreadViaNamedpipe(Global::DataFromServerMsgTypeToTsfWorkerThread::VModeChanged, + FormatVModeWorkerPayload()); // 「双拼显示全拼」让原始按键样式在双拼下改由 Server 回包驱动,换方案时 TSF 要跟着换。 BroadcastToTsfWorkerThreadViaNamedpipe(Global::DataFromServerMsgTypeToTsfWorkerThread::PagingCommaPeriodChanged, FormatPagingCommaPeriodWorkerPayload()); diff --git a/server/src/window/ime_windows_candidate.cpp b/server/src/window/ime_windows_candidate.cpp index 8e177387..a3b832e4 100644 --- a/server/src/window/ime_windows_candidate.cpp +++ b/server/src/window/ime_windows_candidate.cpp @@ -523,6 +523,7 @@ LRESULT CALLBACK WndProcCandWindow(HWND hwnd, UINT message, WPARAM wParam, LPARA const std::wstring previous_mid_sentence_helpcode_semicolon = FormatMidSentenceHelpcodeSemicolonWorkerPayload(); const std::wstring previous_direct_helpcode = FormatDirectHelpcodeWorkerPayload(); + const std::wstring previous_v_mode = FormatVModeWorkerPayload(); const std::wstring previous_mid_sentence_helpcode_uppercase = FormatMidSentenceHelpcodeUppercaseWorkerPayload(); const std::wstring previous_paging_worker_payload = FormatPagingCommaPeriodWorkerPayload(); @@ -700,6 +701,11 @@ LRESULT CALLBACK WndProcCandWindow(HWND hwnd, UINT message, WPARAM wParam, LPARA Global::DataFromServerMsgTypeToTsfWorkerThread::DirectHelpcodeChanged, FormatDirectHelpcodeWorkerPayload()); } + if (previous_v_mode != FormatVModeWorkerPayload()) + { + BroadcastToTsfWorkerThreadViaNamedpipe(Global::DataFromServerMsgTypeToTsfWorkerThread::VModeChanged, + FormatVModeWorkerPayload()); + } if (previous_mid_sentence_helpcode_uppercase != FormatMidSentenceHelpcodeUppercaseWorkerPayload()) { BroadcastToTsfWorkerThreadViaNamedpipe( diff --git a/server/src/window/ime_windows_settings.cpp b/server/src/window/ime_windows_settings.cpp index 14951031..b3149b3e 100644 --- a/server/src/window/ime_windows_settings.cpp +++ b/server/src/window/ime_windows_settings.cpp @@ -359,6 +359,7 @@ LRESULT CALLBACK WndProcSettingsWindow(HWND hwnd, UINT message, WPARAM wParam, L const std::wstring previous_mid_sentence_helpcode_semicolon = FormatMidSentenceHelpcodeSemicolonWorkerPayload(); const std::wstring previous_direct_helpcode = FormatDirectHelpcodeWorkerPayload(); + const std::wstring previous_v_mode = FormatVModeWorkerPayload(); const std::wstring previous_mid_sentence_helpcode_uppercase = FormatMidSentenceHelpcodeUppercaseWorkerPayload(); const std::wstring previous_paging_worker_payload = FormatPagingCommaPeriodWorkerPayload(); @@ -525,6 +526,11 @@ LRESULT CALLBACK WndProcSettingsWindow(HWND hwnd, UINT message, WPARAM wParam, L Global::DataFromServerMsgTypeToTsfWorkerThread::DirectHelpcodeChanged, FormatDirectHelpcodeWorkerPayload()); } + if (previous_v_mode != FormatVModeWorkerPayload()) + { + BroadcastToTsfWorkerThreadViaNamedpipe(Global::DataFromServerMsgTypeToTsfWorkerThread::VModeChanged, + FormatVModeWorkerPayload()); + } if (previous_mid_sentence_helpcode_uppercase != FormatMidSentenceHelpcodeUppercaseWorkerPayload()) { BroadcastToTsfWorkerThreadViaNamedpipe( diff --git a/server/tests/CMakeLists.txt b/server/tests/CMakeLists.txt index 0941626d..860e415a 100644 --- a/server/tests/CMakeLists.txt +++ b/server/tests/CMakeLists.txt @@ -9,6 +9,7 @@ add_test(NAME voice_control_dispatch COMMAND test_voice_control_dispatch) set(TEST_SOURCE_FILES "${CMAKE_CURRENT_SOURCE_DIR}/src/main.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_date_time_query.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/test_v_mode_query.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_emoji_query.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_kaomoji_query.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_jianpin_query.cpp" diff --git a/server/tests/src/test_ipc_protocol_constants.cpp b/server/tests/src/test_ipc_protocol_constants.cpp index 48482516..3038152b 100644 --- a/server/tests/src/test_ipc_protocol_constants.cpp +++ b/server/tests/src/test_ipc_protocol_constants.cpp @@ -59,7 +59,8 @@ TEST_CASE(ipc_pipe_ready_is_a_distinct_server_reply) REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeSemicolonChanged, 29u); REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::DirectHelpcodeChanged, 30u); REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeUppercaseChanged, 31u); - REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::MaxKnown, 31u); + REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::VModeChanged, 32u); + REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::MaxKnown, 32u); REQUIRE(Global::DataFromServerMsgTypeToTsfWorkerThread::FocusSessionReady > Global::DataFromServerMsgTypeToTsfWorkerThread::PagingCommaPeriodChanged); REQUIRE(Global::DataFromServerMsgTypeToTsfWorkerThread::PipeReady > @@ -106,8 +107,10 @@ TEST_CASE(ipc_pipe_ready_is_a_distinct_server_reply) Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeSemicolonChanged); REQUIRE(Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeUppercaseChanged > Global::DataFromServerMsgTypeToTsfWorkerThread::DirectHelpcodeChanged); + REQUIRE(Global::DataFromServerMsgTypeToTsfWorkerThread::VModeChanged > + Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeUppercaseChanged); REQUIRE_EQ(Global::DataFromServerMsgTypeToTsfWorkerThread::MaxKnown, - Global::DataFromServerMsgTypeToTsfWorkerThread::MidSentenceHelpcodeUppercaseChanged); + Global::DataFromServerMsgTypeToTsfWorkerThread::VModeChanged); } TEST_CASE(ipc_client_suspension_is_a_distinct_nonterminal_route_reset) diff --git a/server/tests/src/test_v_mode_query.cpp b/server/tests/src/test_v_mode_query.cpp new file mode 100644 index 00000000..3d820b37 --- /dev/null +++ b/server/tests/src/test_v_mode_query.cpp @@ -0,0 +1,45 @@ +#include "tests/includes/test_framework.h" +#include "engine/contracts/v_mode_input.h" +#include "engine/local_modes/v_mode_query.h" + +#include + +// V 模式:V 后接数字转中文,接算式给结果。Server 按原串识别 V 模式、取候选,规则与 TSF 共用。 +TEST_CASE(v_mode_converts_numbers_to_chinese) +{ + const auto integer = metasequoia::local_modes::query_v_mode("123"); + REQUIRE_EQ(integer.size(), static_cast(4)); + REQUIRE_EQ(integer[0].word, std::string("一百二十三")); + REQUIRE_EQ(integer[1].word, std::string("壹佰贰拾叁")); + REQUIRE(integer[0].source == CandidateSource::Generated); + + const auto money = metasequoia::local_modes::query_v_mode("123.45"); + REQUIRE_EQ(money[0].word, std::string("壹佰贰拾叁元肆角伍分")); + REQUIRE_EQ(money[1].word, std::string("一百二十三点四五")); +} + +TEST_CASE(v_mode_evaluates_arithmetic) +{ + const auto result = metasequoia::local_modes::query_v_mode("1+2*3"); + REQUIRE_EQ(result.size(), static_cast(2)); + REQUIRE_EQ(result[0].word, std::string("7")); + REQUIRE_EQ(result[1].word, std::string("1+2*3=7")); + REQUIRE(metasequoia::local_modes::query_v_mode("1/0").empty()); + REQUIRE(metasequoia::local_modes::query_v_mode("1+").empty()); +} + +TEST_CASE(v_mode_composition_follows_the_scheme_trigger) +{ + using FanyImeVModeInput::Trigger; + const std::string upper = "V12"; + const std::string lower = "v12"; + const std::string word = "vip"; + // 全拼大小写都认;双拼只认大写;关着都不认。 + REQUIRE(FanyImeVModeInput::IsComposition(upper.data(), upper.size(), Trigger::AnyCase)); + REQUIRE(FanyImeVModeInput::IsComposition(lower.data(), lower.size(), Trigger::AnyCase)); + REQUIRE(FanyImeVModeInput::IsComposition(upper.data(), upper.size(), Trigger::UppercaseOnly)); + REQUIRE(!FanyImeVModeInput::IsComposition(lower.data(), lower.size(), Trigger::UppercaseOnly)); + REQUIRE(!FanyImeVModeInput::IsComposition(upper.data(), upper.size(), Trigger::Off)); + // 小写 v 后面接字母仍是普通输入。 + REQUIRE(!FanyImeVModeInput::IsComposition(word.data(), word.size(), Trigger::AnyCase)); +} diff --git a/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts b/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts index 270a5fd6..0b218ca9 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts @@ -266,6 +266,9 @@ function applyConfigData(data: Record, target?: string): void { if (typeof data?.utility?.r_mode === 'boolean') { applyToggleState('rModeToggleBtn', data.utility.r_mode); } + if (typeof data?.utility?.v_mode === 'boolean') { + applyToggleState('vModeToggleBtn', data.utility.v_mode); + } if (typeof data?.utility?.clipboard_history === 'boolean') { applyToggleState('clipboardHistoryToggleBtn', data.utility.clipboard_history); } diff --git a/ui-html/webview2/settings/ime-settings/src/modules/tools-settings.ts b/ui-html/webview2/settings/ime-settings/src/modules/tools-settings.ts index 6a748870..f78771f5 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/tools-settings.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/tools-settings.ts @@ -194,6 +194,9 @@ export function setupToolsSettings(): void { setupToggleButton('rModeToggleBtn', (active) => { updateConfig('utility.r_mode', active); }); + setupToggleButton('vModeToggleBtn', (active) => { + updateConfig('utility.v_mode', active); + }); document.getElementById('quickPhraseSearchButton')?.addEventListener('click', () => query()); document.getElementById('quickPhraseSearch')?.addEventListener('keydown', event => { if ((event as KeyboardEvent).key === 'Enter') query(); }); document.getElementById('quickPhraseAddButton')?.addEventListener('click', () => openDialog()); diff --git a/ui-html/webview2/settings/ime-settings/src/partials/tools-settings.html b/ui-html/webview2/settings/ime-settings/src/partials/tools-settings.html index 31382ffe..d490bdd7 100644 --- a/ui-html/webview2/settings/ime-settings/src/partials/tools-settings.html +++ b/ui-html/webview2/settings/ime-settings/src/partials/tools-settings.html @@ -232,6 +232,18 @@ +
+
+
+
数字与计算(V 模式)
+
中文模式下输入 V 再接数字或算式:V123 → 一百二十三 / 壹佰贰拾叁;V123.45 → 壹佰贰拾叁元肆角伍分;V1+2*3 → 7(支持 + - * / 和括号)。双拼按 Shift+V;全拼大小写 v 都可以。空格上屏;数字键用来输入,Shift+数字选词。
+
+
+
+
+
+
+