From 33a86c11b63cd07323254c7d52182dda9b7f683d Mon Sep 17 00:00:00 2001 From: fanlusky <1730976608@qq.com> Date: Sat, 8 Aug 2026 12:37:10 +0800 Subject: [PATCH] Add date-time quick input (T-mode) feature, strengthen installation to not overwrite user's `config.toml`, and add a cold-start protection for FTB that "displays within the page ready / up to 6s grace period, then shows/hides according to actual status" (still rebuilds `webview2` on upgrade). --- server/CMakeLists.txt | 2 + server/assets/config/config.toml | 1 + server/scripts/config_files/CMakeLists.txt | 2 + server/src/config/ime_config.cpp | 281 ++++++------ server/src/config/ime_config.h | 5 +- server/src/date-time/date_time_query.cpp | 110 +++++ server/src/date-time/date_time_query.h | 13 + server/src/ipc/event_listener.cpp | 354 ++++++++-------- server/src/settings/settings_app.cpp | 5 +- server/src/webview2/windows_webview2.cpp | 400 ++++++++++-------- server/src/webview2/windows_webview2.h | 7 + .../floating_toolbar_visibility_policy.h | 8 + server/src/window/ime_windows.cpp | 56 ++- server/src/window/ime_windows.h | 3 + server/tests/CMakeLists.txt | 2 + server/tests/src/test_date_time_query.cpp | 57 +++ ...est_floating_toolbar_visibility_policy.cpp | 6 + 17 files changed, 829 insertions(+), 483 deletions(-) create mode 100644 server/src/date-time/date_time_query.cpp create mode 100644 server/src/date-time/date_time_query.h create mode 100644 server/tests/src/test_date_time_query.cpp diff --git a/server/CMakeLists.txt b/server/CMakeLists.txt index f8a96489..ae3e7798 100644 --- a/server/CMakeLists.txt +++ b/server/CMakeLists.txt @@ -92,6 +92,8 @@ set(SRC_FILES "./src/config/ime_config.h" "./src/conversion/chinese_converter.cpp" "./src/conversion/chinese_converter.h" + "./src/date-time/date_time_query.cpp" + "./src/date-time/date_time_query.h" "./src/defines/defines.h" "./src/english/english_ime.cpp" "./src/english/english_ime.h" diff --git a/server/assets/config/config.toml b/server/assets/config/config.toml index bd70f0c0..3b7e5eca 100644 --- a/server/assets/config/config.toml +++ b/server/assets/config/config.toml @@ -209,6 +209,7 @@ clipboard_history = "false" study_english_word = "false" # 背单词功能,开启后可以在候选项旁边显示英文 quick_phrase = true # Shift+K 进入快捷短语模式 unicode_mode = true # Shift+U 进入 Unicode 码位输入模式 +date_time_mode = true # Shift+T 进入日期与时间快捷输入模式 # # AI Assistant diff --git a/server/scripts/config_files/CMakeLists.txt b/server/scripts/config_files/CMakeLists.txt index b0759fb1..0a03e3dc 100644 --- a/server/scripts/config_files/CMakeLists.txt +++ b/server/scripts/config_files/CMakeLists.txt @@ -40,6 +40,8 @@ include_directories("./MetasequoiaImeEngine/utfcpp/source/") set(MY_EXECUTABLE_NAME "MetasequoiaImeServer") set(SRC_FILES + "./src/date-time/date_time_query.cpp" + "./src/date-time/date_time_query.h" "./src/config/ime_config.cpp" "./src/config/ime_config.h" "./src/defines/defines.h" diff --git a/server/src/config/ime_config.cpp b/server/src/config/ime_config.cpp index 6e21a38e..ade5a2c3 100644 --- a/server/src/config/ime_config.cpp +++ b/server/src/config/ime_config.cpp @@ -55,6 +55,7 @@ bool g_english_candidates_enabled = false; bool g_cloud_candidates_enabled = true; bool g_unicode_mode_enabled = true; bool g_quick_phrase_enabled = true; +bool g_date_time_mode_enabled = true; bool g_paging_minus_equal_enabled = true; bool g_paging_comma_period_enabled = false; bool g_paging_tab_enabled = true; @@ -100,7 +101,10 @@ class ConfigFileLock CloseHandle(handle_); } - explicit operator bool() const { return locked_; } + explicit operator bool() const + { + return locked_; + } private: HANDLE handle_ = nullptr; @@ -351,8 +355,8 @@ void ForEachTomlAssignment(const std::string &text, const TomlAssignmentVisitor { const size_t equals = line.find('='); const std::string key = equals == std::string::npos ? std::string() : Trim(line.substr(0, equals)); - const size_t value_offset = equals == std::string::npos ? std::string::npos - : line.find_first_not_of(" \t", equals + 1); + const size_t value_offset = + equals == std::string::npos ? std::string::npos : line.find_first_not_of(" \t", equals + 1); if (!key.empty() && value_offset != std::string::npos) { const size_t value_begin = line_begin + value_offset; @@ -379,10 +383,10 @@ std::string MakeTomlAssignmentId(const std::string §ion, const std::string & std::map ParseTomlAssignments(const std::string &text) { std::map values; - ForEachTomlAssignment(text, [&](const std::string §ion, const std::string &key, size_t value_begin, - size_t value_end) { - values[MakeTomlAssignmentId(section, key)] = text.substr(value_begin, value_end - value_begin); - }); + ForEachTomlAssignment( + text, [&](const std::string §ion, const std::string &key, size_t value_begin, size_t value_end) { + values[MakeTomlAssignmentId(section, key)] = text.substr(value_begin, value_end - value_begin); + }); return values; } @@ -399,22 +403,22 @@ std::string MergeTomlIntoTemplate(const std::string &template_text, }; std::vector patches; - ForEachTomlAssignment(template_text, [&](const std::string §ion, const std::string &key, size_t value_begin, - size_t value_end) { - const std::string id = MakeTomlAssignmentId(section, key); - const auto user = user_values.find(id); - if (user == user_values.end()) - { - return; - } - // 仍等于上一版默认值,说明用户没动过这一项,让新版默认值生效。 - const auto baseline = baseline_values.find(id); - if (baseline != baseline_values.end() && baseline->second == user->second) - { - return; - } - patches.push_back({value_begin, value_end, user->second}); - }); + ForEachTomlAssignment( + template_text, [&](const std::string §ion, const std::string &key, size_t value_begin, size_t value_end) { + const std::string id = MakeTomlAssignmentId(section, key); + const auto user = user_values.find(id); + if (user == user_values.end()) + { + return; + } + // 仍等于上一版默认值,说明用户没动过这一项,让新版默认值生效。 + const auto baseline = baseline_values.find(id); + if (baseline != baseline_values.end() && baseline->second == user->second) + { + return; + } + patches.push_back({value_begin, value_end, user->second}); + }); std::string merged = template_text; for (auto patch = patches.rbegin(); patch != patches.rend(); ++patch) @@ -500,38 +504,31 @@ bool LoadImeConfig() g_candidate_font = tbl["appearance"]["font"].value_or(std::string("Noto Sans SC")); if (g_candidate_font.empty()) g_candidate_font = "Noto Sans SC"; - g_candidate_english_font = - tbl["appearance"]["english_font"].value_or(std::string("Segoe UI")); + g_candidate_english_font = tbl["appearance"]["english_font"].value_or(std::string("Segoe UI")); if (g_candidate_english_font.empty()) g_candidate_english_font = "Segoe UI"; - g_candidate_default_font = - tbl["appearance"]["default_font"].value_or(std::string("Microsoft YaHei")); + g_candidate_default_font = tbl["appearance"]["default_font"].value_or(std::string("Microsoft YaHei")); if (g_candidate_default_font.empty()) g_candidate_default_font = "Microsoft YaHei"; { const int font_size = tbl["appearance"]["font_size"].value_or(16); - g_candidate_font_size = font_size >= kCandidateFontSizeMin && font_size <= kCandidateFontSizeMax - ? font_size - : 16; + g_candidate_font_size = + font_size >= kCandidateFontSizeMin && font_size <= kCandidateFontSizeMax ? font_size : 16; } { - const std::string color = - tbl["appearance"]["cand_text_color"].value_or(std::string("auto")); + const std::string color = tbl["appearance"]["cand_text_color"].value_or(std::string("auto")); g_candidate_text_color = color.empty() ? "auto" : color; } g_session_backend = tbl["input"]["session_backend"].value_or(std::string("legacy")); g_input_scheme = ParseScheme(tbl["input"]["schema"].value_or(std::string("shuangpin"))); - const std::string character_set = - tbl["input"]["character_set"].value_or(std::string("simplified")); + const std::string character_set = tbl["input"]["character_set"].value_or(std::string("simplified")); g_character_set = character_set == "traditional" ? "traditional" : "simplified"; { - const std::string mode = - tbl["input"]["default_ime_mode"].value_or(std::string("chinese")); + const std::string mode = tbl["input"]["default_ime_mode"].value_or(std::string("chinese")); g_default_ime_mode = mode == "english" ? "english" : "chinese"; } { - const std::string scope = - tbl["input"]["ime_mode_scope"].value_or(std::string("app")); + const std::string scope = tbl["input"]["ime_mode_scope"].value_or(std::string("app")); g_ime_mode_scope = scope == "global" ? "global" : "app"; } g_shuangpin_schema = tbl["input"]["shuangpin_schema"].value_or(std::string("xiaohe")); @@ -541,8 +538,9 @@ bool LoadImeConfig() g_quanpin_helpcode_enabled = tbl["helpcode"]["quanpin_helpcode"].value_or(true); const std::string shuangpin_helpcode_schema = tbl["helpcode"]["shuangpin_helpcode_schema"].value_or(std::string("lantian")); - g_shuangpin_helpcode_schema = - HelpcodeUtils::is_supported_helpcode_schema(shuangpin_helpcode_schema) ? shuangpin_helpcode_schema : "lantian"; + g_shuangpin_helpcode_schema = HelpcodeUtils::is_supported_helpcode_schema(shuangpin_helpcode_schema) + ? shuangpin_helpcode_schema + : "lantian"; const std::string quanpin_helpcode_schema = tbl["helpcode"]["quanpin_helpcode_schema"].value_or(std::string("lantian")); g_quanpin_helpcode_schema = @@ -552,22 +550,17 @@ bool LoadImeConfig() g_show_quanpin_helpcode_in_candidate_window = tbl["helpcode"]["show_qp_helpcode_in_candidate_window"].value_or(true); g_floating_toolbar_enabled = tbl["general"]["floating_toolbar"].value_or(true); - g_floating_toolbar_items.fullwidth = - tbl["general"]["floating_toolbar_fullwidth"].value_or(true); - g_floating_toolbar_items.punctuation = - tbl["general"]["floating_toolbar_punctuation"].value_or(true); - g_floating_toolbar_items.character_set = - tbl["general"]["floating_toolbar_character_set"].value_or(true); - g_floating_toolbar_items.emoji = - tbl["general"]["floating_toolbar_emoji"].value_or(true); - g_floating_toolbar_items.screen_keyboard = - tbl["general"]["floating_toolbar_screen_keyboard"].value_or(false); - g_floating_toolbar_items.settings = - tbl["general"]["floating_toolbar_settings"].value_or(true); + g_floating_toolbar_items.fullwidth = tbl["general"]["floating_toolbar_fullwidth"].value_or(true); + g_floating_toolbar_items.punctuation = tbl["general"]["floating_toolbar_punctuation"].value_or(true); + g_floating_toolbar_items.character_set = tbl["general"]["floating_toolbar_character_set"].value_or(true); + g_floating_toolbar_items.emoji = tbl["general"]["floating_toolbar_emoji"].value_or(true); + g_floating_toolbar_items.screen_keyboard = tbl["general"]["floating_toolbar_screen_keyboard"].value_or(false); + g_floating_toolbar_items.settings = tbl["general"]["floating_toolbar_settings"].value_or(true); g_english_candidates_enabled = tbl["general"]["cn_en_mixed_input"].value_or(false); g_cloud_candidates_enabled = tbl["general"]["cloud_candidates"].value_or(true); g_unicode_mode_enabled = tbl["utility"]["unicode_mode"].value_or(true); g_quick_phrase_enabled = tbl["utility"]["quick_phrase"].value_or(true); + g_date_time_mode_enabled = tbl["utility"]["date_time_mode"].value_or(true); const auto legacy_paging_mode = tbl["general"]["paging_mode"].value(); g_paging_minus_equal_enabled = tbl["general"]["paging_minus_equal"].value_or(!legacy_paging_mode || *legacy_paging_mode == "-/="); @@ -599,19 +592,17 @@ bool LoadImeConfig() legacy_ctrl_alt_space = true; } } - g_switch_language_shift_enabled = - tbl["keybindings"]["switch_language_shift"].value_or(legacy_shift); - g_switch_language_ctrl_enabled = - tbl["keybindings"]["switch_language_ctrl"].value_or(false); + g_switch_language_shift_enabled = tbl["keybindings"]["switch_language_shift"].value_or(legacy_shift); + g_switch_language_ctrl_enabled = tbl["keybindings"]["switch_language_ctrl"].value_or(false); g_switch_language_ctrl_alt_space_enabled = tbl["keybindings"]["switch_language_ctrl_alt_space"].value_or(legacy_ctrl_alt_space); } { - const std::string mode = - tbl["frequency_adjustment"]["mode"].value_or(std::string("promote")); + const std::string mode = tbl["frequency_adjustment"]["mode"].value_or(std::string("promote")); g_frequency_adjustment.mode = mode == "disabled" || mode == "pin" || mode == "halve" || mode == "linear" || mode == "promote" - ? mode : "promote"; + ? mode + : "promote"; const int trigger = tbl["frequency_adjustment"]["trigger_count"].value_or(1); const int step = tbl["frequency_adjustment"]["linear_step"].value_or(1); g_frequency_adjustment.trigger_count = trigger >= 1 && trigger <= 10 ? trigger : 1; @@ -643,13 +634,11 @@ bool LoadImeConfig() g_theme_emoji = normalize_surface(tbl["appearance"]["theme_emoji"].value_or(std::string("follow"))); g_theme_screen_keyboard = normalize_surface(tbl["appearance"]["theme_screen_keyboard"].value_or(std::string("follow"))); - g_theme_handwriting = - normalize_surface(tbl["appearance"]["theme_handwriting"].value_or(std::string("follow"))); + g_theme_handwriting = normalize_surface(tbl["appearance"]["theme_handwriting"].value_or(std::string("follow"))); g_theme_voice = normalize_surface(tbl["appearance"]["theme_voice"].value_or(std::string("follow"))); { - const std::string tsf_preedit_style = - tbl["appearance"]["tsf_preedit_style"].value_or( - tbl["input"]["tsf_preedit_style"].value_or(std::string("raw"))); + const std::string tsf_preedit_style = tbl["appearance"]["tsf_preedit_style"].value_or( + tbl["input"]["tsf_preedit_style"].value_or(std::string("raw"))); g_tsf_preedit_style = GlobalSettings::normalizeTsfPreeditStyle(tsf_preedit_style); GlobalSettings::setTsfPreeditStyle(g_tsf_preedit_style); } @@ -658,8 +647,7 @@ bool LoadImeConfig() g_voice_input.hotkey_ctrl_f9 = tbl["voice_input"]["hotkey_ctrl_f9"].value_or(true); g_voice_input.hotkey_ctrl_win = tbl["voice_input"]["hotkey_ctrl_win"].value_or(false); g_voice_input.hotkey_rctrl_ralt = tbl["voice_input"]["hotkey_rctrl_ralt"].value_or(false); - g_voice_input.hotkey_hold_space_lock = - tbl["voice_input"]["hotkey_hold_space_lock"].value_or(true); + g_voice_input.hotkey_hold_space_lock = tbl["voice_input"]["hotkey_hold_space_lock"].value_or(true); g_voice_input.asr_provider = tbl["voice_input"]["asr_provider"].value_or(std::string("siliconflow")); g_voice_input.asr_token = tbl["voice_input"]["asr_token"].value_or(std::string()); g_voice_input.asr_endpoint = tbl["voice_input"]["asr_endpoint"].value_or( @@ -680,8 +668,8 @@ bool LoadImeConfig() g_ai_assistant.enabled = tbl["ai_assistant"]["enabled"].value_or(false); g_ai_assistant.provider = tbl["ai_assistant"]["provider"].value_or(std::string("deepseek")); g_ai_assistant.token = tbl["ai_assistant"]["token"].value_or(std::string()); - g_ai_assistant.endpoint = tbl["ai_assistant"]["endpoint"].value_or( - std::string("https://api.deepseek.com/chat/completions")); + g_ai_assistant.endpoint = + tbl["ai_assistant"]["endpoint"].value_or(std::string("https://api.deepseek.com/chat/completions")); g_ai_assistant.model = tbl["ai_assistant"]["model"].value_or(std::string("deepseek-v4-flash")); const int ai_limit = tbl["ai_assistant"]["candidate_limit"].value_or(3); g_ai_assistant.candidate_limit = ai_limit >= 1 && ai_limit <= 10 ? ai_limit : 3; @@ -744,29 +732,41 @@ bool WriteConfiguredValue(const std::string §ion, const std::string &key, co void MigrateLegacyVoiceInputConfig() { - if (!g_voice_input.asr_token.empty()) return; - const std::filesystem::path legacy_path = std::filesystem::path(CommonUtils::get_local_appdata_path()) / - "MetasequoiaVoiceInput" / "config.toml"; - if (!std::filesystem::exists(legacy_path)) return; + if (!g_voice_input.asr_token.empty()) + return; + const std::filesystem::path legacy_path = + std::filesystem::path(CommonUtils::get_local_appdata_path()) / "MetasequoiaVoiceInput" / "config.toml"; + if (!std::filesystem::exists(legacy_path)) + return; try { const toml::table legacy = toml::parse_file(legacy_path.string()); const std::string asr_token = legacy["asr_api"]["token"].value_or(std::string()); - if (asr_token.empty()) return; + if (asr_token.empty()) + return; const auto migrate_string = [](const std::string &key, const std::string &value, std::string &target) { - if (WriteConfiguredValue("voice_input", key, EscapeTomlBasicString(value))) target = value; + if (WriteConfiguredValue("voice_input", key, EscapeTomlBasicString(value))) + target = value; }; const auto migrate_bool = [](const std::string &key, bool value, bool &target) { - if (WriteConfiguredValue("voice_input", key, value ? "true" : "false")) target = value; + if (WriteConfiguredValue("voice_input", key, value ? "true" : "false")) + target = value; }; - migrate_string("asr_provider", legacy["asr_api"]["provider"].value_or(std::string("siliconflow")), g_voice_input.asr_provider); + migrate_string("asr_provider", legacy["asr_api"]["provider"].value_or(std::string("siliconflow")), + g_voice_input.asr_provider); migrate_string("asr_token", asr_token, g_voice_input.asr_token); - migrate_string("asr_endpoint", legacy["asr_api"]["endpoint"].value_or(g_voice_input.asr_endpoint), g_voice_input.asr_endpoint); - migrate_string("polish_provider", legacy["polish_api"]["provider"].value_or(std::string("siliconflow")), g_voice_input.polish_provider); - migrate_string("polish_token", legacy["polish_api"]["token"].value_or(std::string()), g_voice_input.polish_token); - migrate_string("polish_endpoint", legacy["polish_api"]["endpoint"].value_or(g_voice_input.polish_endpoint), g_voice_input.polish_endpoint); - migrate_string("language", legacy["settings"]["language"].value_or(std::string("zh-cn")), g_voice_input.language); - migrate_bool("notification_sound", legacy["settings"]["notification_sound"].value_or(true), g_voice_input.notification_sound); + migrate_string("asr_endpoint", legacy["asr_api"]["endpoint"].value_or(g_voice_input.asr_endpoint), + g_voice_input.asr_endpoint); + migrate_string("polish_provider", legacy["polish_api"]["provider"].value_or(std::string("siliconflow")), + g_voice_input.polish_provider); + migrate_string("polish_token", legacy["polish_api"]["token"].value_or(std::string()), + g_voice_input.polish_token); + migrate_string("polish_endpoint", legacy["polish_api"]["endpoint"].value_or(g_voice_input.polish_endpoint), + g_voice_input.polish_endpoint); + migrate_string("language", legacy["settings"]["language"].value_or(std::string("zh-cn")), + g_voice_input.language); + migrate_bool("notification_sound", legacy["settings"]["notification_sound"].value_or(true), + g_voice_input.notification_sound); migrate_bool("polish_text", legacy["settings"]["polish_text"].value_or(false), g_voice_input.polish_text); } catch (const toml::parse_error &) @@ -794,8 +794,7 @@ SchemeType ParseScheme(const std::string &value) std::string MergeConfigIntoTemplate(const std::string &template_text, const std::string &user_text, const std::string &baseline_text) { - return MergeTomlIntoTemplate(template_text, ParseTomlAssignments(user_text), - ParseTomlAssignments(baseline_text)); + return MergeTomlIntoTemplate(template_text, ParseTomlAssignments(user_text), ParseTomlAssignments(baseline_text)); } void InitImeConfig() @@ -1046,7 +1045,7 @@ std::string ResolveSystemFontFamilyForCss(const std::string &font) ComPtr typographic_names; BOOL has_typographic_names = FALSE; if (SUCCEEDED(resolved_font->GetInformationalStrings(DWRITE_INFORMATIONAL_STRING_TYPOGRAPHIC_FAMILY_NAMES, - &typographic_names, &has_typographic_names)) && + &typographic_names, &has_typographic_names)) && has_typographic_names) { const std::wstring name = GetPreferredLocalizedFontName(typographic_names.Get()); @@ -1056,8 +1055,7 @@ std::string ResolveSystemFontFamilyForCss(const std::string &font) ComPtr family; ComPtr family_names; - if (SUCCEEDED(resolved_font->GetFontFamily(&family)) && family && - SUCCEEDED(family->GetFamilyNames(&family_names))) + if (SUCCEEDED(resolved_font->GetFontFamily(&family)) && family && SUCCEEDED(family->GetFamilyNames(&family_names))) { const std::wstring name = GetPreferredLocalizedFontName(family_names.Get()); if (!name.empty()) @@ -1661,8 +1659,8 @@ bool IsSystemAppsLightTheme() } DWORD value = 0; DWORD size = sizeof(value); - const LONG result = RegQueryValueExW(key, L"AppsUseLightTheme", nullptr, nullptr, reinterpret_cast(&value), - &size); + const LONG result = + RegQueryValueExW(key, L"AppsUseLightTheme", nullptr, nullptr, reinterpret_cast(&value), &size); RegCloseKey(key); return result == ERROR_SUCCESS && value != 0; } @@ -1782,18 +1780,29 @@ const VoiceInputConfig &GetConfiguredVoiceInput() bool SetConfiguredVoiceInputString(const std::string &key, const std::string &value) { - if (key == "language" && value != "zh-cn" && value != "en" && value != "auto") return false; - if (key == "commit_mode" && value != "tsf" && value != "sendinput" && value != "ctrl_v") return false; + if (key == "language" && value != "zh-cn" && value != "en" && value != "auto") + return false; + if (key == "commit_mode" && value != "tsf" && value != "sendinput" && value != "ctrl_v") + return false; std::string *target = nullptr; - if (key == "asr_provider") target = &g_voice_input.asr_provider; - else if (key == "asr_token") target = &g_voice_input.asr_token; - else if (key == "asr_endpoint") target = &g_voice_input.asr_endpoint; - else if (key == "polish_provider") target = &g_voice_input.polish_provider; - else if (key == "polish_token") target = &g_voice_input.polish_token; - else if (key == "polish_endpoint") target = &g_voice_input.polish_endpoint; - else if (key == "language") target = &g_voice_input.language; - else if (key == "commit_mode") target = &g_voice_input.commit_mode; - if (!target || !WriteConfiguredValue("voice_input", key, EscapeTomlBasicString(value))) return false; + if (key == "asr_provider") + target = &g_voice_input.asr_provider; + else if (key == "asr_token") + target = &g_voice_input.asr_token; + else if (key == "asr_endpoint") + target = &g_voice_input.asr_endpoint; + else if (key == "polish_provider") + target = &g_voice_input.polish_provider; + else if (key == "polish_token") + target = &g_voice_input.polish_token; + else if (key == "polish_endpoint") + target = &g_voice_input.polish_endpoint; + else if (key == "language") + target = &g_voice_input.language; + else if (key == "commit_mode") + target = &g_voice_input.commit_mode; + if (!target || !WriteConfiguredValue("voice_input", key, EscapeTomlBasicString(value))) + return false; *target = value; return true; } @@ -1843,18 +1852,42 @@ bool SetConfiguredQuickPhraseEnabled(bool enabled) return true; } +bool GetConfiguredDateTimeModeEnabled() +{ + return g_date_time_mode_enabled; +} + +bool SetConfiguredDateTimeModeEnabled(bool enabled) +{ + if (!WriteConfiguredValue("utility", "date_time_mode", enabled ? "true" : "false")) + { + return false; + } + g_date_time_mode_enabled = enabled; + return true; +} + bool SetConfiguredVoiceInputBool(const std::string &key, bool value) { bool *target = nullptr; - if (key == "voice_input") target = &g_voice_input.enabled; - else if (key == "hotkey_ralt") target = &g_voice_input.hotkey_ralt; - else if (key == "hotkey_ctrl_f9") target = &g_voice_input.hotkey_ctrl_f9; - else if (key == "hotkey_ctrl_win") target = &g_voice_input.hotkey_ctrl_win; - else if (key == "hotkey_rctrl_ralt") target = &g_voice_input.hotkey_rctrl_ralt; - else if (key == "hotkey_hold_space_lock") target = &g_voice_input.hotkey_hold_space_lock; - else if (key == "notification_sound") target = &g_voice_input.notification_sound; - else if (key == "polish_text") target = &g_voice_input.polish_text; - if (!target || !WriteConfiguredValue("voice_input", key, value ? "true" : "false")) return false; + if (key == "voice_input") + target = &g_voice_input.enabled; + else if (key == "hotkey_ralt") + target = &g_voice_input.hotkey_ralt; + else if (key == "hotkey_ctrl_f9") + target = &g_voice_input.hotkey_ctrl_f9; + else if (key == "hotkey_ctrl_win") + target = &g_voice_input.hotkey_ctrl_win; + else if (key == "hotkey_rctrl_ralt") + target = &g_voice_input.hotkey_rctrl_ralt; + else if (key == "hotkey_hold_space_lock") + target = &g_voice_input.hotkey_hold_space_lock; + else if (key == "notification_sound") + target = &g_voice_input.notification_sound; + else if (key == "polish_text") + target = &g_voice_input.polish_text; + if (!target || !WriteConfiguredValue("voice_input", key, value ? "true" : "false")) + return false; *target = value; return true; } @@ -1884,27 +1917,36 @@ bool SetConfiguredFrequencyAdjustmentInt(const std::string &key, int value) if ((key != "trigger_count" && key != "linear_step") || value < 1 || value > 10 || !WriteConfiguredValue("frequency_adjustment", key, std::to_string(value))) return false; - if (key == "trigger_count") g_frequency_adjustment.trigger_count = value; - else g_frequency_adjustment.linear_step = value; + if (key == "trigger_count") + g_frequency_adjustment.trigger_count = value; + else + g_frequency_adjustment.linear_step = value; return true; } bool SetConfiguredAiAssistantString(const std::string &key, const std::string &value) { std::string *target = nullptr; - if (key == "provider") target = &g_ai_assistant.provider; - else if (key == "token") target = &g_ai_assistant.token; - else if (key == "endpoint") target = &g_ai_assistant.endpoint; - else if (key == "model") target = &g_ai_assistant.model; - else if (key == "prompt") target = &g_ai_assistant.prompt; - if (!target || !WriteConfiguredValue("ai_assistant", key, EscapeTomlBasicString(value))) return false; + if (key == "provider") + target = &g_ai_assistant.provider; + else if (key == "token") + target = &g_ai_assistant.token; + else if (key == "endpoint") + target = &g_ai_assistant.endpoint; + else if (key == "model") + target = &g_ai_assistant.model; + else if (key == "prompt") + target = &g_ai_assistant.prompt; + if (!target || !WriteConfiguredValue("ai_assistant", key, EscapeTomlBasicString(value))) + return false; *target = value; return true; } bool SetConfiguredAiAssistantBool(const std::string &key, bool value) { - if (key != "enabled" || !WriteConfiguredValue("ai_assistant", key, value ? "true" : "false")) return false; + if (key != "enabled" || !WriteConfiguredValue("ai_assistant", key, value ? "true" : "false")) + return false; g_ai_assistant.enabled = value; return true; } @@ -1912,7 +1954,8 @@ bool SetConfiguredAiAssistantBool(const std::string &key, bool value) bool SetConfiguredAiAssistantInt(const std::string &key, int value) { if (key != "candidate_limit" || value < 1 || value > 10 || - !WriteConfiguredValue("ai_assistant", key, std::to_string(value))) return false; + !WriteConfiguredValue("ai_assistant", key, std::to_string(value))) + return false; g_ai_assistant.candidate_limit = value; return true; } diff --git a/server/src/config/ime_config.h b/server/src/config/ime_config.h index 80b14b95..a0fa0cf2 100644 --- a/server/src/config/ime_config.h +++ b/server/src/config/ime_config.h @@ -35,7 +35,8 @@ struct AiAssistantConfig std::string endpoint = "https://api.deepseek.com/chat/completions"; std::string model = "deepseek-v4-flash"; int candidate_limit = 3; - std::string prompt = R"PROMPT(你是一个中文全拼输入法联想引擎。输入为已经切分好的拼音数组、前文上下文和候选数量。 + std::string prompt = + R"PROMPT(你是一个中文全拼输入法联想引擎。输入为已经切分好的拼音数组、前文上下文和候选数量。 优先生成与拼音严格对应的中文候选:若有 N 段拼音,首选必须尽量为 N 个汉字,每段拼音对应一个汉字,不得随意增删或改变读音。结合上下文、常用程度、语义完整性和固定搭配排序。 @@ -146,6 +147,8 @@ bool GetConfiguredUnicodeModeEnabled(); bool SetConfiguredUnicodeModeEnabled(bool enabled); bool GetConfiguredQuickPhraseEnabled(); bool SetConfiguredQuickPhraseEnabled(bool enabled); +bool GetConfiguredDateTimeModeEnabled(); +bool SetConfiguredDateTimeModeEnabled(bool enabled); bool GetConfiguredPagingMinusEqualEnabled(); bool SetConfiguredPagingMinusEqualEnabled(bool enabled); bool GetConfiguredPagingCommaPeriodEnabled(); diff --git a/server/src/date-time/date_time_query.cpp b/server/src/date-time/date_time_query.cpp new file mode 100644 index 00000000..7a341af5 --- /dev/null +++ b/server/src/date-time/date_time_query.cpp @@ -0,0 +1,110 @@ +#include "date-time/date_time_query.h" + +#include +#include + +namespace +{ +bool IsDateKeyword(const std::string &keyword) +{ + return keyword == "rq" || keyword == "riqi" || keyword == "date"; +} + +bool IsTimeKeyword(const std::string &keyword) +{ + return keyword == "sj" || keyword == "shijian" || keyword == "time"; +} + +std::string Format(const char *pattern, unsigned first, unsigned second = 0, unsigned third = 0) +{ + char buffer[96] = {}; + std::snprintf(buffer, sizeof(buffer), pattern, first, second, third); + return buffer; +} + +std::string ChineseDigits(unsigned value) +{ + static constexpr const char *kDigits[] = {"〇", "一", "二", "三", "四", "五", "六", "七", "八", "九"}; + std::string result; + for (char digit : std::to_string(value)) + result += kDigits[digit - '0']; + return result; +} + +std::string ChineseNumber(unsigned value) +{ + static constexpr const char *kDigits[] = {"", "一", "二", "三", "四", "五", "六", "七", "八", "九"}; + if (value < 10) + return kDigits[value]; + if (value == 10) + return "十"; + if (value < 20) + return std::string("十") + kDigits[value - 10]; + if (value % 10 == 0) + return std::string(kDigits[value / 10]) + "十"; + return std::string(kDigits[value / 10]) + "十" + kDigits[value % 10]; +} + +std::vector DateCandidates(const SYSTEMTIME &now) +{ + static constexpr const char *kWeekdays[] = {"星期日", "星期一", "星期二", "星期三", "星期四", "星期五", "星期六"}; + const unsigned year = now.wYear; + const unsigned month = now.wMonth; + const unsigned day = now.wDay; + return { + Format("%u年%u月%u日", year, month, day), + Format("%04u-%02u-%02u", year, month, day), + Format("%04u/%02u/%02u", year, month, day), + Format("%04u.%02u.%02u", year, month, day), + Format("%04u%02u%02u", year, month, day), + ChineseDigits(year) + "年" + ChineseNumber(month) + "月" + ChineseNumber(day) + "日", + Format("%u月%u日", month, day), + Format("%u年%u月%u日 ", year, month, day) + kWeekdays[(std::min)(now.wDayOfWeek, 6)], + }; +} + +std::vector TimeCandidates(const SYSTEMTIME &now) +{ + const unsigned hour = now.wHour; + const unsigned minute = now.wMinute; + const unsigned second = now.wSecond; + const unsigned hour12 = hour % 12 == 0 ? 12 : hour % 12; + const std::string period = hour < 12 ? "上午" : "下午"; + return { + Format("%02u:%02u:%02u", hour, minute, second), + Format("%02u:%02u", hour, minute), + Format("%02u时%02u分%02u秒", hour, minute, second), + Format("%02u时%02u分", hour, minute), + period + Format("%u:%02u:%02u", hour12, minute, second), + period + Format("%u时%02u分%02u秒", hour12, minute, second), + Format("%02u%02u%02u", hour, minute, second), + Format("%04u-%02u-%02u ", now.wYear, now.wMonth, now.wDay) + Format("%02u:%02u:%02u", hour, minute, second), + }; +} +} // namespace + +namespace DateTimeQuery +{ +std::vector Query(const std::string &keyword, const SYSTEMTIME *now, int limit) +{ + std::vector results; + if (limit <= 0 || (!IsDateKeyword(keyword) && !IsTimeKeyword(keyword))) + return results; + + SYSTEMTIME current = {}; + if (now) + current = *now; + else + GetLocalTime(¤t); + + const auto candidates = IsDateKeyword(keyword) ? DateCandidates(current) : TimeCandidates(current); + const size_t result_count = (std::min)(candidates.size(), static_cast(limit)); + results.reserve(result_count); + for (size_t index = 0; index < result_count; ++index) + { + results.emplace_back("", candidates[index], static_cast(result_count - index), + CandidateSource::Generated); + } + return results; +} +} // namespace DateTimeQuery diff --git a/server/src/date-time/date_time_query.h b/server/src/date-time/date_time_query.h new file mode 100644 index 00000000..449ff581 --- /dev/null +++ b/server/src/date-time/date_time_query.h @@ -0,0 +1,13 @@ +#pragma once + +#include "MetasequoiaImeEngine/core/word_item.h" +#include +#include +#include + +namespace DateTimeQuery +{ +// `keyword` is the text after the leading 'T'. The optional SYSTEMTIME makes +// the formatter deterministic in tests; nullptr uses the current local time. +std::vector Query(const std::string &keyword, const SYSTEMTIME *now = nullptr, int limit = 8); +} // namespace DateTimeQuery diff --git a/server/src/ipc/event_listener.cpp b/server/src/ipc/event_listener.cpp index 0b144612..f5dbf7b2 100644 --- a/server/src/ipc/event_listener.cpp +++ b/server/src/ipc/event_listener.cpp @@ -41,6 +41,7 @@ #include "session/session_factory.h" #include "quick-phrases/quick_phrase_query.h" #include "unicode/unicode_query.h" +#include "date-time/date_time_query.h" #include "log/ftb_diag_log.h" #include @@ -53,6 +54,7 @@ namespace std::string BuildCurrentCandidatePage(); bool g_quick_phrase_triggered = false; bool g_unicode_mode_triggered = false; +bool g_date_time_mode_triggered = false; bool g_english_input_mode = false; // Sticky UILess for the active Main-pipe client. When set, never raise the // WebView2 candidate HWND — hosts (games) draw via ITfUIElementSink instead. @@ -119,21 +121,40 @@ bool IsQuickPhraseInput(const std::string &raw) bool IsUnicodeCompositionActive(const std::string &raw) { - if (!g_unicode_mode_triggered || raw.empty() || raw.front() != 'U') return false; + if (!g_unicode_mode_triggered || raw.empty() || raw.front() != 'U') + return false; size_t index = 1; - if (index < raw.size() && raw[index] == '+') ++index; + if (index < raw.size() && raw[index] == '+') + ++index; return std::all_of(raw.begin() + static_cast(index), raw.end(), [](unsigned char ch) { return IsHexChar(ch); }); } bool IsUnicodeInput(const std::string &raw) { - if (!IsUnicodeCompositionActive(raw) || raw.size() <= 1) return false; + if (!IsUnicodeCompositionActive(raw) || raw.size() <= 1) + return false; size_t index = 1; - if (raw[index] == '+') ++index; + if (raw[index] == '+') + ++index; return index < raw.size(); } +bool IsDateTimeCompositionActive(const std::string &raw) +{ + return g_date_time_mode_triggered && !raw.empty() && raw.front() == 'T' && + std::all_of(raw.begin() + 1, raw.end(), [](unsigned char ch) { return ch >= 'a' && ch <= 'z'; }); +} + +bool IsDateTimeInput(const std::string &raw) +{ + if (!IsDateTimeCompositionActive(raw) || raw.size() <= 1) + return false; + const std::string keyword = raw.substr(1); + return keyword == "rq" || keyword == "riqi" || keyword == "date" || keyword == "sj" || keyword == "shijian" || + keyword == "time"; +} + constexpr auto kPipeHelloTimeout = std::chrono::seconds(2); class ScopedPipeClientHandler @@ -305,16 +326,17 @@ void UpdateEnglishInput(const std::string &input, uint64_t client_id = 0, uint64 std::lock_guard lock(g_async_request_mutex); EnglishIme::OnInputChanged(input, dedicated_mode); ++g_english_generation; - g_english_request_origin = - input.empty() ? AsyncRequestOrigin{} - : AsyncRequestOrigin{client_id, activation_epoch, g_english_generation, input}; + g_english_request_origin = input.empty() + ? AsyncRequestOrigin{} + : AsyncRequestOrigin{client_id, activation_epoch, g_english_generation, input}; } std::vector SplitPinyin(const std::string &segmentation) { std::vector result; boost::split(result, segmentation, boost::is_any_of("' "), boost::token_compress_on); - result.erase(std::remove_if(result.begin(), result.end(), [](const std::string &item) { return item.empty(); }), result.end()); + result.erase(std::remove_if(result.begin(), result.end(), [](const std::string &item) { return item.empty(); }), + result.end()); return result; } @@ -336,8 +358,8 @@ void UpdateAiInput(const std::string &identity, uint64_t client_id = 0, uint64_t } AiAssistant::OnInputChanged(std::move(request)); ++g_ai_generation; - g_ai_request_origin = usable ? AsyncRequestOrigin{client_id, activation_epoch, g_ai_generation, identity} - : AsyncRequestOrigin{}; + g_ai_request_origin = + usable ? AsyncRequestOrigin{client_id, activation_epoch, g_ai_generation, identity} : AsyncRequestOrigin{}; } AsyncRequestOrigin FindCloudRequestOrigin(const std::string &input, uint64_t generation) @@ -363,7 +385,8 @@ AsyncRequestOrigin FindEnglishRequestOrigin(const std::string &input, uint64_t g AsyncRequestOrigin FindAiRequestOrigin(const std::string &input, uint64_t generation) { std::lock_guard lock(g_async_request_mutex); - if (g_ai_request_origin.generation == generation && g_ai_request_origin.input == input) return g_ai_request_origin; + if (g_ai_request_origin.generation == generation && g_ai_request_origin.input == input) + return g_ai_request_origin; return {}; } @@ -422,8 +445,7 @@ bool IsCommitWithHighlightedCandidatePunctuationInCandidateMode(UINT keycode, WC L'>', // L'?' // }; - return kCommitWithHighlightedCandidatePunctuation.find(wch) != - kCommitWithHighlightedCandidatePunctuation.end(); + return kCommitWithHighlightedCandidatePunctuation.find(wch) != kCommitWithHighlightedCandidatePunctuation.end(); } bool IsManualPinyinSeparatorKey(UINT keycode, WCHAR wch) @@ -434,7 +456,8 @@ bool IsManualPinyinSeparatorKey(UINT keycode, WCHAR wch) bool IsSelectionKey(UINT keycode) { - if (keycode == VK_SPACE) return true; + if (keycode == VK_SPACE) + return true; if (keycode >= '0' && keycode <= '9') { const std::string raw = g_inputSession ? g_inputSession->get_pinyin_sequence_with_cases() : std::string{}; @@ -521,9 +544,8 @@ bool ApplyCompositionEditKey(UINT keycode, WCHAR wch) { return false; } - if (input == '\'' && - ((composition.caret_position > 0 && raw[composition.caret_position - 1] == '\'') || - (composition.caret_position < raw.size() && raw[composition.caret_position] == '\''))) + if (input == '\'' && ((composition.caret_position > 0 && raw[composition.caret_position - 1] == '\'') || + (composition.caret_position < raw.size() && raw[composition.caret_position] == '\''))) { return true; } @@ -711,8 +733,7 @@ bool IsImplicitActivationEvent(UINT event_type) void SendFocusSessionReady(const PipeClientActivation &activation) { - if (!FanyImeIpc::CanSendFocusSessionReady(activation.client_id, activation.epoch, - activation.focus_token)) + if (!FanyImeIpc::CanSendFocusSessionReady(activation.client_id, activation.epoch, activation.focus_token)) { return; } @@ -721,10 +742,9 @@ void SendFocusSessionReady(const PipeClientActivation &activation) // endpoint used for candidate commits. The activation request id is a TSF // focus token and is echoed verbatim; unlike the Server-only epoch, it lets // TSF reject a buffered marker from an older focus session. - SendToTsfWorkerThreadClientViaNamedpipe( - activation.client_id, activation.epoch, - Global::DataFromServerMsgTypeToTsfWorkerThread::FocusSessionReady, - std::to_wstring(activation.focus_token)); + SendToTsfWorkerThreadClientViaNamedpipe(activation.client_id, activation.epoch, + Global::DataFromServerMsgTypeToTsfWorkerThread::FocusSessionReady, + std::to_wstring(activation.focus_token)); } bool IsKnownMainPipeEvent(UINT event_type) @@ -771,8 +791,7 @@ bool IsValidMainPipeFrame(const FanyImeNamedpipeData &pipe_data) { return false; } - if (pipe_data.event_type == FanyImePipeEventType::ClientActivated && - pipe_data.request_id == 0) + if (pipe_data.event_type == FanyImePipeEventType::ClientActivated && pipe_data.request_id == 0) { // FocusSessionReady can never acknowledge token zero. Reject the // activation instead of creating a server epoch that TSF cannot fence. @@ -811,10 +830,9 @@ bool ReadPipeHello(HANDLE pipe, UINT expected_pipe_role, FanyImePipeHello &hello } DWORD bytesRead = 0; const bool readResult = ReadExactPipeMessageUntil(pipe, &hello, sizeof(hello), - std::chrono::steady_clock::now() + kPipeHelloTimeout, - bytesRead); - return readResult && pipe_running && hello.client_id != 0 && - hello.pipe_role == expected_pipe_role && PipeClientIdMatchesConnectedProcess(pipe, hello.client_id); + std::chrono::steady_clock::now() + kPipeHelloTimeout, bytesRead); + return readResult && pipe_running && hello.client_id != 0 && hello.pipe_role == expected_pipe_role && + PipeClientIdMatchesConnectedProcess(pipe, hello.client_id); } void WakePipeListener(const wchar_t *pipe_name) @@ -929,8 +947,10 @@ struct Task std::string CurrentRankingContextKey() { std::string converted = g_inputSession->get_quanpin(); - if (converted.empty()) converted = g_inputSession->get_pinyin_segmentation(); - if (g_inputSession->get_pinyin_sequence().size() == 1) return converted; + if (converted.empty()) + converted = g_inputSession->get_pinyin_segmentation(); + if (g_inputSession->get_pinyin_sequence().size() == 1) + return converted; std::string plain = converted; plain.erase(std::remove(plain.begin(), plain.end(), '\''), plain.end()); const auto cuts = quanpin::cut_pinyin_by_mode(plain, "correction"); @@ -947,12 +967,16 @@ std::string EnglishRankingContextKey() std::string CandidateDatabaseKey(const WordItem &item, const std::string &context_key) { - if (!item.canonical_pinyin.empty()) return item.canonical_pinyin; - if (g_inputSession->get_pinyin_sequence().size() == 1) return item.pinyin; + if (!item.canonical_pinyin.empty()) + return item.canonical_pinyin; + if (g_inputSession->get_pinyin_sequence().size() == 1) + return item.pinyin; auto segments = quanpin::split_segments(context_key); const size_t han_count = HelpcodeUtils::count_han_chars(item.word); - if (segments.empty() || han_count == 0) return item.pinyin; - if (segments.size() > han_count) segments.resize(han_count); + if (segments.empty() || han_count == 0) + return item.pinyin; + if (segments.size() > han_count) + segments.resize(han_count); return quanpin::join_segments(segments); } @@ -962,13 +986,11 @@ std::mutex queueMutex; void PrepareCandidateList(uint64_t client_id, uint64_t activation_epoch); void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t request_id); void ClearState(); -void ProcessSelectionKey(UINT keycode, uint64_t client_id, uint64_t activation_epoch, - int forced_index_in_page = -1); +void ProcessSelectionKey(UINT keycode, uint64_t client_id, uint64_t activation_epoch, int forced_index_in_page = -1); void ApplyCloudCandidate(const std::string &candidate, const std::string &pinyin, uint64_t generation); void ApplyAiCandidate(const std::string &candidate, const std::string &identity, uint64_t generation); void ApplyEnglishCandidates(std::vector candidates, const std::string &input, uint64_t generation); -void EnqueueStoreUserPhraseTask(const std::string &pinyin, const std::string &word, - bool pinyin_is_canonical = false); +void EnqueueStoreUserPhraseTask(const std::string &pinyin, const std::string &word, bool pinyin_is_canonical = false); void EnqueuePinCandidateTask(const std::string &pinyin, const std::string &word); bool ResolveCandidateItem(int one_based_index, WordItem &item); bool SendCurrentDataToClient(uint64_t client_id, uint64_t activation_epoch, uint64_t request_id); @@ -989,14 +1011,12 @@ void WorkerThread() taskQueue.pop(); } - if (task.type == TaskType::ClientDeactivated || - task.type == TaskType::ClientSuspended) + if (task.type == TaskType::ClientDeactivated || task.type == TaskType::ClientSuspended) { if (!IsPipeActivationCurrent(0, task.activation_epoch)) { FTB_DIAG_LOGF(L"task {} epoch={} rejected as stale", - task.type == TaskType::ClientDeactivated ? L"ClientDeactivated" - : L"ClientSuspended", + task.type == TaskType::ClientDeactivated ? L"ClientDeactivated" : L"ClientSuspended", task.activation_epoch); continue; } @@ -1009,13 +1029,11 @@ void WorkerThread() continue; } - const bool candidateUiAction = task.type == TaskType::UiCommitCandidate || - task.type == TaskType::UiPinCandidate || - task.type == TaskType::UiDeleteCandidate || - task.type == TaskType::UiFixCandidatePosition || - task.type == TaskType::UiClearCandidatePosition; - if (candidateUiAction && - !CandidateUiOwnerIsCurrent({task.client_id, task.activation_epoch})) + const bool candidateUiAction = + task.type == TaskType::UiCommitCandidate || task.type == TaskType::UiPinCandidate || + task.type == TaskType::UiDeleteCandidate || task.type == TaskType::UiFixCandidatePosition || + task.type == TaskType::UiClearCandidatePosition; + if (candidateUiAction && !CandidateUiOwnerIsCurrent({task.client_id, task.activation_epoch})) { // The page was hidden or replaced after the click was posted. continue; @@ -1101,8 +1119,7 @@ void WorkerThread() case TaskType::StoreUserPhrase: { if (task.session_pinyin_is_canonical) { - g_inputSession->store_user_phrase_from_canonical_pinyin(task.session_pinyin, - task.session_word); + g_inputSession->store_user_phrase_from_canonical_pinyin(task.session_pinyin, task.session_word); } else { @@ -1236,7 +1253,8 @@ void WorkerThread() const int packed_state = (effective_cn << 2) | (fullwidth_state << 1) | punctuation_state; if (FanyImeIpc::ShouldResetCompositionForImeMode(effective_cn != 0)) { - if (effective_cn == 0) g_english_input_mode = false; + if (effective_cn == 0) + g_english_input_mode = false; PostMessage(::global_hwnd, WM_HIDE_MAIN_WINDOW, 0, 0); ClearState(); } @@ -1246,8 +1264,7 @@ void WorkerThread() } case TaskType::UiCommitCandidate: { - ProcessSelectionKey(0, task.client_id, task.activation_epoch, - task.candidate_one_based_index - 1); + ProcessSelectionKey(0, task.client_id, task.activation_epoch, task.candidate_one_based_index - 1); if (Global::MsgTypeToTsf == Global::DataFromServerMsgType::Normal) { // The worker packet is the complete edit-session-owned commit @@ -1266,9 +1283,9 @@ void WorkerThread() // NeedToCreateWord/OutOfRange require the normal reply's // subtype. An empty worker packet is only the ordered trigger; // TSF consumes (rather than discards) the id-0 reply. - SendToTsfWorkerThreadClientViaNamedpipe( - task.client_id, task.activation_epoch, - Global::DataFromServerMsgTypeToTsfWorkerThread::CommitCandidate, L""); + SendToTsfWorkerThreadClientViaNamedpipe(task.client_id, task.activation_epoch, + Global::DataFromServerMsgTypeToTsfWorkerThread::CommitCandidate, + L""); } break; } @@ -1302,9 +1319,9 @@ void WorkerThread() else if (task.type == TaskType::UiDeleteCandidate) { if (english_candidate) - (void)user_dictionary::delete_english_candidate( - CommonUtils::get_ime_data_path() + "\\english.db", user_dictionary::default_user_db_path(), - entry_key, item.word); + (void)user_dictionary::delete_english_candidate(CommonUtils::get_ime_data_path() + "\\english.db", + user_dictionary::default_user_db_path(), entry_key, + item.word); else if (utf8::distance(item.word.begin(), item.word.end()) == 1) { break; @@ -1438,7 +1455,8 @@ void EnqueueCloudCandidate(const std::string &candidate, const std::string &piny void EnqueueAiCandidate(const std::string &candidate, const std::string &identity, uint64_t generation) { const AsyncRequestOrigin origin = FindAiRequestOrigin(identity, generation); - if (origin.client_id == 0 || origin.activation_epoch == 0) return; + if (origin.client_id == 0 || origin.activation_epoch == 0) + return; { std::lock_guard lock(queueMutex); Task task; @@ -1474,8 +1492,7 @@ void EnqueueEnglishCandidates(std::vector candidates, const std::strin pipe_queueCv.notify_one(); } -void EnqueueStoreUserPhraseTask(const std::string &pinyin, const std::string &word, - bool pinyin_is_canonical) +void EnqueueStoreUserPhraseTask(const std::string &pinyin, const std::string &word, bool pinyin_is_canonical) { { std::lock_guard lock(queueMutex); @@ -1539,7 +1556,8 @@ void EnqueueCandidateUiAction(CandidateUiAction action, int one_based_index, int } else if (action == CandidateUiAction::FixPosition) { - if (fixed_position < 1 || fixed_position > 5) return; + if (fixed_position < 1 || fixed_position > 5) + return; type = TaskType::UiFixCandidatePosition; } else if (action == CandidateUiAction::ClearPosition) @@ -1647,8 +1665,8 @@ bool SendUiLessCompositionToClient(uint64_t client_id, uint64_t activation_epoch const std::wstring page = BuildUiLessCandidatePageW(); ::WriteDataToSharedMemory(page, true); auto &ui = Global::candidate_ui; - const int selection = - ui.page_words.empty() ? 0 + const int selection = ui.page_words.empty() + ? 0 : std::clamp(ui.selected_index_in_page, 0, static_cast(ui.page_words.size()) - 1); Global::MsgTypeToTsf = Global::DataFromServerMsgType::UiLessComposition; Global::candidate_ui.selected_text = preedit + L'\t' + page + L'\t' + std::to_wstring(selection); @@ -1738,11 +1756,10 @@ void MainPipeClientThread(HANDLE clientPipe, uint64_t handlerId) { FanyImeNamedpipeData pipeData = {}; DWORD bytesRead = 0; - const BOOL readResult = helloReceived - ? ReadFile(clientPipe, &pipeData, sizeof(pipeData), &bytesRead, nullptr) - : ReadExactPipeMessageUntil(clientPipe, &pipeData, sizeof(pipeData), - std::chrono::steady_clock::now() + kPipeHelloTimeout, - bytesRead); + const BOOL readResult = + helloReceived ? ReadFile(clientPipe, &pipeData, sizeof(pipeData), &bytesRead, nullptr) + : ReadExactPipeMessageUntil(clientPipe, &pipeData, sizeof(pipeData), + std::chrono::steady_clock::now() + kPipeHelloTimeout, bytesRead); if (!readResult || bytesRead != sizeof(pipeData)) { LogPipeReadFailure(L"main-pipe", bytesRead); @@ -1814,24 +1831,20 @@ void MainPipeClientThread(HANDLE clientPipe, uint64_t handlerId) } if (FanyImePipeEventType::IsRouteDeactivation(pipeData.event_type)) { - const bool terminalDeactivation = - FanyImePipeEventType::IsTerminalDeactivation(pipeData.event_type); - LogClientLifecycle(terminalDeactivation ? L"deactivated" : L"suspended", - clientId, pipeData.event_type); + const bool terminalDeactivation = FanyImePipeEventType::IsTerminalDeactivation(pipeData.event_type); + LogClientLifecycle(terminalDeactivation ? L"deactivated" : L"suspended", clientId, pipeData.event_type); uint64_t deactivationEpoch = DeactivatePipeClient(clientId, mainRegistrationId); if (terminalDeactivation && deactivationEpoch == 0) { // ClientSuspended may already have put routing into the // inactive state. Preserve exact terminal cleanup for that // owner; a subsequent activation makes this task stale. - deactivationEpoch = - ResolvePipeClientTerminalDeactivationEpoch(clientId); + deactivationEpoch = ResolvePipeClientTerminalDeactivationEpoch(clientId); } if (deactivationEpoch != 0) { - EnqueueTask(terminalDeactivation ? TaskType::ClientDeactivated - : TaskType::ClientSuspended, - pipeData, deactivationEpoch); + EnqueueTask(terminalDeactivation ? TaskType::ClientDeactivated : TaskType::ClientSuspended, pipeData, + deactivationEpoch); } continue; } @@ -1854,8 +1867,8 @@ void MainPipeClientThread(HANDLE clientPipe, uint64_t handlerId) } } - const bool isActiveClient = activation.client_id == clientId && activation.epoch != 0 && - IsActivePipeClient(clientId, activation.epoch); + const bool isActiveClient = + activation.client_id == clientId && activation.epoch != 0 && IsActivePipeClient(clientId, activation.epoch); LogClientRouting(clientId, pipeData.event_type, isActiveClient); if (!isActiveClient) { @@ -1924,8 +1937,7 @@ void MainPipeClientThread(HANDLE clientPipe, uint64_t handlerId) // A process can disconnect its Main pipe after it suspended the route. // Reuse only that owner's inactive epoch; a replacement Main that has // already activated makes this terminal cleanup stale. - disconnectEpoch = - ResolvePipeClientTerminalDeactivationEpoch(clientId); + disconnectEpoch = ResolvePipeClientTerminalDeactivationEpoch(clientId); } if (disconnectEpoch != 0) { @@ -2055,8 +2067,7 @@ void ToTsfWorkerThreadPipeEventListenerLoopThread() { try { - std::thread(RegisteredPipeMonitorThread, clientPipe, FanyImePipeRole::ToTsfWorkerThread, - handlerId) + std::thread(RegisteredPipeMonitorThread, clientPipe, FanyImePipeRole::ToTsfWorkerThread, handlerId) .detach(); } catch (...) @@ -2137,8 +2148,7 @@ void RegisteredPipeMonitorThread(HANDLE clientPipe, UINT pipeRole, uint64_t hand return; } - const wchar_t *pipeName = - pipeRole == FanyImePipeRole::ToTsf ? L"to-tsf-pipe" : L"to-tsf-worker-pipe"; + const wchar_t *pipeName = pipeRole == FanyImePipeRole::ToTsf ? L"to-tsf-pipe" : L"to-tsf-worker-pipe"; while (pipe_running && IsPipeClientRegistrationCurrent(hello.client_id, pipeRole, registrationId)) { DWORD bytesAvailable = 0; @@ -2233,31 +2243,21 @@ void AuxPipeEventListenerLoopThread() { unsigned long long clientId = 0; unsigned long long focusToken = 0; - if (swscanf_s(message.c_str(), - L"TerminalDeactivation|%llu|%llu", - &clientId, &focusToken) == 2 && - PipeClientIdMatchesConnectedProcess( - listeningPipe, static_cast(clientId))) + if (swscanf_s(message.c_str(), L"TerminalDeactivation|%llu|%llu", &clientId, &focusToken) == 2 && + PipeClientIdMatchesConnectedProcess(listeningPipe, static_cast(clientId))) { - const uint64_t deactivationEpoch = - DeactivatePipeClientByFocusToken( - static_cast(clientId), - static_cast(focusToken)); + const uint64_t deactivationEpoch = DeactivatePipeClientByFocusToken( + static_cast(clientId), static_cast(focusToken)); if (deactivationEpoch != 0) { FanyImeNamedpipeData pipeData = {}; - pipeData.event_type = - FanyImePipeEventType::ClientDeactivated; - pipeData.client_id = - static_cast(clientId); - EnqueueTask(TaskType::ClientDeactivated, pipeData, - deactivationEpoch); + pipeData.event_type = FanyImePipeEventType::ClientDeactivated; + pipeData.client_id = static_cast(clientId); + EnqueueTask(TaskType::ClientDeactivated, pipeData, deactivationEpoch); constexpr wchar_t acknowledgement[] = L"OK"; DWORD bytesWritten = 0; - WriteFile(listeningPipe, acknowledgement, - sizeof(acknowledgement) - - sizeof(wchar_t), + WriteFile(listeningPipe, acknowledgement, sizeof(acknowledgement) - sizeof(wchar_t), &bytesWritten, nullptr); } } @@ -2302,17 +2302,20 @@ void PrepareCandidateList(uint64_t client_id, uint64_t activation_epoch) { items = QuickPhraseQuery::QueryPrefix(current_input.substr(1)); } + else if (IsDateTimeInput(current_input)) + { + items = DateTimeQuery::Query(current_input.substr(1)); + } else { items = g_inputSession->get_candidates(); user_dictionary::apply_fixed_positions( user_dictionary::default_user_db_path(), CurrentRankingContextKey(), items, g_inputSession->get_pinyin_sequence().size() == 1, - [](const std::string &key, const std::string &value) { - return g_inputSession->find_candidate(key, value); - }, + [](const std::string &key, const std::string &value) { return g_inputSession->find_candidate(key, value); }, g_inputSession->has_active_helpcode()); - if (g_inputSession->get_pinyin_sequence().size() == 1 && items.size() > 24) items.resize(24); + if (g_inputSession->get_pinyin_sequence().size() == 1 && items.size() > 24) + items.resize(24); } if (items.empty() && !g_english_input_mode) @@ -2330,8 +2333,8 @@ void PrepareCandidateList(uint64_t client_id, uint64_t activation_epoch) UpdateEnglishInput(current_input, client_id, activation_epoch, true); } else if (!IsQuickPhraseInput(current_input) && !IsUnicodeInput(current_input) && - GetConfiguredEnglishCandidatesEnabled() && scheme != SchemeType::Wubi && - !GlobalIme::composition.creating_word.active) + !IsDateTimeCompositionActive(current_input) && GetConfiguredEnglishCandidatesEnabled() && + scheme != SchemeType::Wubi && !GlobalIme::composition.creating_word.active) { UpdateEnglishInput(current_input, client_id, activation_epoch); } @@ -2375,8 +2378,7 @@ void ApplyCloudCandidate(const std::string &candidate, const std::string &pinyin size_t insert_index = items.size() >= 1 ? 1 : 0; items.insert(items.begin() + insert_index, WordItem(pinyin, candidate, 1, CandidateSource::CloudSuggestion)); - g_inputSession->cache_dynamic_candidate(cloud_query_state.cache_key, candidate, - CandidateSource::CloudSuggestion); + g_inputSession->cache_dynamic_candidate(cloud_query_state.cache_key, candidate, CandidateSource::CloudSuggestion); Global::cloud_candidate = {true, candidate, cloud_query_state.committed_pinyin}; Global::candidate_ui.item_total_count = static_cast(items.size()); @@ -2462,16 +2464,15 @@ void ApplyEnglishCandidates(std::vector candidates, const std::string RefreshCandidatePageUi(true); return; } - items.erase(std::remove_if(items.begin(), items.end(), [](const WordItem &item) { - return item.source == CandidateSource::EnglishDictionary; - }), + items.erase(std::remove_if(items.begin(), items.end(), + [](const WordItem &item) { return item.source == CandidateSource::EnglishDictionary; }), items.end()); std::vector unique_candidates; for (auto &candidate : candidates) { - const bool duplicate = std::any_of(items.begin(), items.end(), - [&](const WordItem &item) { return item.word == candidate.word; }); + const bool duplicate = + std::any_of(items.begin(), items.end(), [&](const WordItem &item) { return item.word == candidate.word; }); if (!duplicate) { unique_candidates.push_back(std::move(candidate)); @@ -2486,9 +2487,8 @@ void ApplyEnglishCandidates(std::vector candidates, const std::string ++insert_index; } items.insert(items.begin() + static_cast(insert_index), std::move(unique_candidates.front())); - user_dictionary::apply_fixed_positions( - user_dictionary::default_user_db_path(), CurrentRankingContextKey(), items, false, {}, - g_inputSession->has_active_helpcode()); + user_dictionary::apply_fixed_positions(user_dictionary::default_user_db_path(), CurrentRankingContextKey(), + items, false, {}, g_inputSession->has_active_helpcode()); for (size_t index = 1; index < unique_candidates.size(); ++index) { items.push_back(std::move(unique_candidates[index])); @@ -2525,16 +2525,18 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques return; } - const std::string input_before_key = g_inputSession ? g_inputSession->get_pinyin_sequence_with_cases() : std::string{}; + const std::string input_before_key = + g_inputSession ? g_inputSession->get_pinyin_sequence_with_cases() : std::string{}; const bool shift_only = (Global::ModifiersDown & 0b00000111u) == 0b00000001u; if (!g_english_input_mode && GetConfiguredQuickPhraseEnabled() && input_before_key.empty() && - Global::Keycode == 'K' && Global::Wch == L'K' && - shift_only) + Global::Keycode == 'K' && Global::Wch == L'K' && shift_only) g_quick_phrase_triggered = true; if (!g_english_input_mode && GetConfiguredUnicodeModeEnabled() && input_before_key.empty() && - Global::Keycode == 'U' && Global::Wch == L'U' && - shift_only) + Global::Keycode == 'U' && Global::Wch == L'U' && shift_only) g_unicode_mode_triggered = true; + if (!g_english_input_mode && GetConfiguredDateTimeModeEnabled() && input_before_key.empty() && + Global::Keycode == 'T' && Global::Wch == L'T' && shift_only) + g_date_time_mode_triggered = true; if (FanyImeIpc::IsBackendIndependentCompositionResetKey(Global::Keycode)) { @@ -2558,13 +2560,12 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques const bool is_unicode_hex_digit = unicode_composition_active && !is_unicode_shift_digit_selection && Global::Keycode >= '0' && Global::Keycode <= '9'; const bool is_unicode_plus = unicode_composition_active && Global::Keycode == VK_OEM_PLUS && Global::Wch == L'+'; - const bool is_composition_edit_key = - Global::Keycode == VK_LEFT || Global::Keycode == VK_RIGHT || Global::Keycode == VK_BACK || - (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_highlighted_candidate_punctuation && !is_selection_key && !is_paging_key && - !is_composition_edit_key; + const bool is_composition_edit_key = Global::Keycode == VK_LEFT || Global::Keycode == VK_RIGHT || + Global::Keycode == VK_BACK || + (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_highlighted_candidate_punctuation && !is_selection_key && + !is_paging_key && !is_composition_edit_key; // Punctuation needs a synchronous highlighted-candidate response on the TSF pipe. // Reply before cloud-query and candidate recomputation work so the TSF-side @@ -2577,19 +2578,15 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques { EnsureCandidatePageReady(); auto &ui = Global::candidate_ui; - ui.selected_text = - FanyImeIpc::HighlightedCandidateText(ui.page_words, ui.selected_index_in_page); + 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(); + (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)) + 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 edge = + Global::Wch == L'[' ? FanyImeIpc::HanCharacterEdge::First : FanyImeIpc::HanCharacterEdge::Last; const auto character = FanyImeIpc::ExtractHanCharacter(CandidateTextForOutput(highlighted_item.word), edge); if (character) @@ -2629,12 +2626,17 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques { g_quick_phrase_triggered = false; g_unicode_mode_triggered = false; + g_date_time_mode_triggered = false; } if (!g_english_input_mode && IsUnicodeCompositionActive(GlobalIme::composition.raw_input_with_cases)) { // Keep preedit identical to the typed U/+hex sequence. GlobalIme::composition.segmented_pinyin = GlobalIme::composition.raw_input_with_cases; } + if (!g_english_input_mode && IsDateTimeCompositionActive(GlobalIme::composition.raw_input_with_cases)) + { + GlobalIme::composition.segmented_pinyin = GlobalIme::composition.raw_input_with_cases; + } // // 先判断要不要触发云联想 // 判断依据: @@ -2643,24 +2645,24 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques // // Paging / selection must not bump async generations or re-apply cached // cloud/AI results (that previously reset page_index and re-cached duplicates). - const bool suppress_async_lookup = - is_paging_key || is_selection_key || is_unicode_shift_digit_selection; + const bool suppress_async_lookup = is_paging_key || is_selection_key || is_unicode_shift_digit_selection; const auto cloud_query_state = g_inputSession->get_cloud_query_state(); if (!g_english_input_mode && !suppress_async_lookup && !IsQuickPhraseInput(g_inputSession->get_pinyin_sequence_with_cases()) && - !IsUnicodeInput(g_inputSession->get_pinyin_sequence_with_cases()) && cloud_query_state.should_query) + !IsUnicodeInput(g_inputSession->get_pinyin_sequence_with_cases()) && + !IsDateTimeCompositionActive(g_inputSession->get_pinyin_sequence_with_cases()) && + cloud_query_state.should_query) { UpdateCloudInput(cloud_query_state.query_text, client_id, activation_epoch); } - const bool ai_eligible = !g_english_input_mode && - !IsQuickPhraseInput(g_inputSession->get_pinyin_sequence_with_cases()) && - !IsUnicodeInput(g_inputSession->get_pinyin_sequence_with_cases()) && - g_inputSession->current_scheme_type() != SchemeType::Wubi && - g_inputSession->is_all_complete_pure_pinyin() && - !g_inputSession->has_active_helpcode() && - !GlobalIme::composition.creating_word.active; + const bool ai_eligible = + !g_english_input_mode && !IsQuickPhraseInput(g_inputSession->get_pinyin_sequence_with_cases()) && + !IsUnicodeInput(g_inputSession->get_pinyin_sequence_with_cases()) && + !IsDateTimeCompositionActive(g_inputSession->get_pinyin_sequence_with_cases()) && + g_inputSession->current_scheme_type() != SchemeType::Wubi && g_inputSession->is_all_complete_pure_pinyin() && + !g_inputSession->has_active_helpcode() && !GlobalIme::composition.creating_word.active; if (!suppress_async_lookup) { UpdateAiInput(ai_eligible ? g_inputSession->get_pinyin_segmentation() : std::string{}, client_id, @@ -2714,8 +2716,8 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques } } } - else if (IsUiLessMode() && is_composition_edit_key && Global::Keycode != VK_LEFT && - Global::Keycode != VK_RIGHT && Global::Keycode != VK_BACK) + else if (IsUiLessMode() && is_composition_edit_key && Global::Keycode != VK_LEFT && Global::Keycode != VK_RIGHT && + Global::Keycode != VK_BACK) { PrepareCandidateList(client_id, activation_epoch); SendUiLessCompositionToClient(client_id, activation_epoch, request_id); @@ -2802,9 +2804,8 @@ void HandleImeKey(uint64_t client_id, uint64_t activation_epoch, uint64_t reques }; const auto move_selection = [&](int offset, UINT response_type) { result = response_type; - if (offset > 0 && - (ui.is_selection_at_last_candidate() || - (ui.is_selection_at_current_page_end() && ui.is_next_page_partial_last_page()))) + if (offset > 0 && (ui.is_selection_at_last_candidate() || + (ui.is_selection_at_current_page_end() && ui.is_next_page_partial_last_page()))) { expand_initial_candidates(); } @@ -2891,6 +2892,7 @@ void ClearState() { g_quick_phrase_triggered = false; g_unicode_mode_triggered = false; + g_date_time_mode_triggered = false; ClearCandidateUiOwner(); UpdateCloudInput(""); UpdateEnglishInput(""); @@ -2937,8 +2939,7 @@ bool ResolveCandidateItem(int one_based_index, WordItem &item) return true; } -void ProcessSelectionKey(UINT keycode, uint64_t client_id, uint64_t activation_epoch, - int forced_index_in_page) +void ProcessSelectionKey(UINT keycode, uint64_t client_id, uint64_t activation_epoch, int forced_index_in_page) { /* 先清理一下状态 */ Global::MsgTypeToTsf = Global::DataFromServerMsgType::Normal; @@ -2953,15 +2954,13 @@ void ProcessSelectionKey(UINT keycode, uint64_t client_id, uint64_t activation_e const bool is_direct_selection = forced_index_in_page >= 0; const int index = is_direct_selection ? forced_index_in_page - : (is_space ? Global::candidate_ui.selected_index_in_page - : static_cast(keycode - '1')); + : (is_space ? Global::candidate_ui.selected_index_in_page : static_cast(keycode - '1')); WordItem curWordItem; - const int page_size = Global::candidate_ui.page_size > 0 ? Global::candidate_ui.page_size - : GetConfiguredCandidatePageSize(); + const int page_size = + Global::candidate_ui.page_size > 0 ? Global::candidate_ui.page_size : GetConfiguredCandidatePageSize(); const bool within_page_size = !is_digit_selection || index < page_size; - const bool is_valid_selection = within_page_size && - (is_direct_selection || is_space || is_digit_selection) && index >= 0 && - static_cast(index) < Global::candidate_ui.page_words.size() && + const bool is_valid_selection = within_page_size && (is_direct_selection || is_space || is_digit_selection) && + index >= 0 && static_cast(index) < Global::candidate_ui.page_words.size() && ResolveCandidateItem(index + 1, curWordItem); if (is_valid_selection) @@ -2979,8 +2978,7 @@ void ProcessSelectionKey(UINT keycode, uint64_t client_id, uint64_t activation_e if (curWordItem.source == CandidateSource::EnglishDictionary || curWordItem.source == CandidateSource::QuickPhrase || curWordItem.source == CandidateSource::Generated) { - if (curWordItem.source == CandidateSource::EnglishDictionary && g_english_input_mode && - isNeedUpdateWeight) + if (curWordItem.source == CandidateSource::EnglishDictionary && g_english_input_mode && isNeedUpdateWeight) { const auto &frequency = GetConfiguredFrequencyAdjustment(); (void)user_dictionary::adjust_english_candidate_ranking( @@ -2994,6 +2992,7 @@ void ProcessSelectionKey(UINT keycode, uint64_t client_id, uint64_t activation_e GlobalIme::composition.clear(); g_quick_phrase_triggered = false; g_unicode_mode_triggered = false; + g_date_time_mode_triggered = false; return; } std::string cloudCommittedPinyin; @@ -3014,16 +3013,16 @@ void ProcessSelectionKey(UINT keycode, uint64_t client_id, uint64_t activation_e } isNeedUpdateWeight = false; } - auto selection_transition = g_inputSession->advance_composition_after_selection( - curWordPinyin, curWord, curWordItem.canonical_pinyin); + auto selection_transition = + g_inputSession->advance_composition_after_selection(curWordPinyin, curWord, curWordItem.canonical_pinyin); // A cloud suggestion is an already-composed result returned for the // current query. It must commit as one candidate even when the // returned query spelling is shorter than the raw input (for example // with an abbreviation or an active help-code suffix). Treating it // like an ordinary partial candidate enters word-creation mode, while // the cloud branch below still persists the selected word. - const bool isNeedCreateWord = FanyImeIpc::ShouldEnterCreatingWord( - curWordItem.source, selection_transition.continues_composition); + const bool isNeedCreateWord = + FanyImeIpc::ShouldEnterCreatingWord(curWordItem.source, selection_transition.continues_composition); if (isNeedCreateWord) { /* 候选只消耗了输入的一部分,继续使用剩余输入造词。完整拼音和简拼均可进入。 */ /* 打开造词开关 */ @@ -3091,7 +3090,8 @@ void ProcessSelectionKey(UINT keycode, uint64_t client_id, uint64_t activation_e if (g_ai_context.size() > 1024) { size_t cut = g_ai_context.size() - 1024; - while (cut < g_ai_context.size() && (static_cast(g_ai_context[cut]) & 0xC0) == 0x80) ++cut; + while (cut < g_ai_context.size() && (static_cast(g_ai_context[cut]) & 0xC0) == 0x80) + ++cut; g_ai_context.erase(0, cut); } @@ -3115,8 +3115,8 @@ void ProcessSelectionKey(UINT keycode, uint64_t client_id, uint64_t activation_e bool ranking_changed = false; (void)user_dictionary::adjust_candidate_ranking( CommonUtils::get_ime_data_path() + "\\msime.db", user_dictionary::default_user_db_path(), - ranking_context_key, Global::candidate_ui.items, ranking_entry_key, curWord, - frequency.mode, frequency.linear_step, frequency.trigger_count, false, &ranking_changed); + ranking_context_key, Global::candidate_ui.items, ranking_entry_key, curWord, frequency.mode, + frequency.linear_step, frequency.trigger_count, false, &ranking_changed); if (ranking_changed) { g_inputSession->reset_cache(); diff --git a/server/src/settings/settings_app.cpp b/server/src/settings/settings_app.cpp index d79b6e38..6977f4e9 100644 --- a/server/src/settings/settings_app.cpp +++ b/server/src/settings/settings_app.cpp @@ -247,7 +247,8 @@ void PostConfig() {"linear_step", frequency.linear_step}}}, {"utility", {{"unicode_mode", GetConfiguredUnicodeModeEnabled()}, - {"quick_phrase", GetConfiguredQuickPhraseEnabled()}}}, + {"quick_phrase", GetConfiguredQuickPhraseEnabled()}, + {"date_time_mode", GetConfiguredDateTimeModeEnabled()}}}, {"appearance", {{"candidate_window_layout", GetConfiguredCandidateWindowLayout()}, {"candidate_window_preedit_style", GetConfiguredCandidateWindowPreeditStyle()}, @@ -381,6 +382,8 @@ bool ApplyConfigUpdate(const json::object &data) return SetConfiguredUnicodeModeEnabled(json::value_to(data.at("value"))); if (path == "utility.quick_phrase") return SetConfiguredQuickPhraseEnabled(json::value_to(data.at("value"))); + if (path == "utility.date_time_mode") + return SetConfiguredDateTimeModeEnabled(json::value_to(data.at("value"))); if (path == "general.paging_minus_equal") return SetConfiguredPagingMinusEqualEnabled(json::value_to(data.at("value"))); if (path == "general.paging_tab") diff --git a/server/src/webview2/windows_webview2.cpp b/server/src/webview2/windows_webview2.cpp index 81d75f4b..9a5a1040 100644 --- a/server/src/webview2/windows_webview2.cpp +++ b/server/src/webview2/windows_webview2.cpp @@ -4,6 +4,7 @@ #include "defines/globals.h" #include "utils/common_utils.h" #include "utils/ime_utils.h" +#include "window/floating_toolbar_visibility_policy.h" #include #include #include @@ -30,10 +31,12 @@ namespace json = boost::json; int FineTuneWindow(HWND hwnd); void ApplyConfiguredFloatingToolbarVisibility(const wchar_t *reason); void ApplyConfiguredFloatingToolbarSize(); +void ReconcileFloatingToolbarVisibilityAfterReady(const wchar_t *reason); void ApplyConfiguredInputScheme(); void ApplyConfiguredShuangpinSchema(); bool EnsureSmallWindowsTopmost(const wchar_t *reason); void UpdateSmallWindowWebviewVisibility(HWND hwnd, bool visible); +void ClearFloatingToolbarNavigationState(); std::wstring DescribeTrayMenuHostState(); std::wstring GetAppdataPath(); HRESULT OnEnvironmentCreated(HWND hwnd, HRESULT result, ICoreWebView2Environment *env); @@ -64,6 +67,11 @@ enum class SmallWindowInitState SmallWindowInitState smallWindowInitState = SmallWindowInitState::Idle; int smallWindowInitAttempts = 0; bool pendingTrayMenuShow = false; +bool floatingToolbarNavigationReady = false; +// After NavigationCompleted, keep the host shown briefly so a cold WebView2 +// user-data folder can finish first paint; then reconcile hide/show for real. +bool floatingToolbarPaintGraceActive = false; +bool floatingToolbarNavigationRetryUsed = false; // WebView2 rejects a CreateCoreWebView2Controller request with E_INVALIDARG when // another controller creation on the same environment is still in flight. The // three small windows must therefore be brought up strictly one at a time. @@ -75,7 +83,6 @@ constexpr UINT_PTR kRetrySmallWindowWebviewTimerId = 9001; bool candidateNavigationReady = false; bool menuNavigationReady = false; -bool floatingToolbarNavigationReady = false; bool smallWindowTopmostRequested = false; bool smallWindowTopmostApplied = false; // Guards against scheduling the staggered pin more than once. @@ -310,21 +317,21 @@ void BeginSmallWindowWebviewEnvironmentCreate() const HRESULT createHr = CreateCoreWebView2EnvironmentWithOptions( nullptr, appDataPath.c_str(), options.Get(), - Callback( - [](HRESULT result, ICoreWebView2Environment *env) -> HRESULT { - if (FAILED(result) || !env) - { - OnSmallWindowWebviewInitFailed(FAILED(result) ? result : E_FAIL); - return FAILED(result) ? result : E_FAIL; - } + Callback([](HRESULT result, + ICoreWebView2Environment *env) + -> HRESULT { + if (FAILED(result) || !env) + { + OnSmallWindowWebviewInitFailed(FAILED(result) ? result : E_FAIL); + return FAILED(result) ? result : E_FAIL; + } - smallWindowWebviewEnvironment = env; - smallWindowInitState = SmallWindowInitState::Ready; - smallWindowInitAttempts = 0; - RequestNextSmallWindowController(); - return S_OK; - }) - .Get()); + smallWindowWebviewEnvironment = env; + smallWindowInitState = SmallWindowInitState::Ready; + smallWindowInitAttempts = 0; + RequestNextSmallWindowController(); + return S_OK; + }).Get()); if (FAILED(createHr)) { @@ -370,7 +377,7 @@ void ResetSmallWindowTopmostGate() CancelStaggeredTopmost(); candidateNavigationReady = false; menuNavigationReady = false; - floatingToolbarNavigationReady = false; + ClearFloatingToolbarNavigationState(); smallWindowTopmostRequested = false; smallWindowTopmostApplied = false; smallWindowTopmostScheduled = false; @@ -430,10 +437,9 @@ bool TrayMenuIsOpenToUser() void ApplySmallWindowTopmostStep(SmallWindowTopmostStep step) { - FTB_DIAG_LOGF(L"topmost step {} applying", - step == SmallWindowTopmostStep::Candidate ? L"candidate" - : step == SmallWindowTopmostStep::FloatingToolbar ? L"floating-toolbar" - : L"tray-menu"); + FTB_DIAG_LOGF(L"topmost step {} applying", step == SmallWindowTopmostStep::Candidate ? L"candidate" + : step == SmallWindowTopmostStep::FloatingToolbar ? L"floating-toolbar" + : L"tray-menu"); switch (step) { case SmallWindowTopmostStep::Candidate: @@ -473,8 +479,7 @@ void ApplySmallWindowTopmostStep(SmallWindowTopmostStep step) void CALLBACK SmallWindowTopmostTimerProc(HWND hwnd, UINT, UINT_PTR timerId, DWORD) { KillTimer(hwnd, timerId); - ApplySmallWindowTopmostStep( - static_cast(timerId - kTopmostStepTimerIdBase)); + ApplySmallWindowTopmostStep(static_cast(timerId - kTopmostStepTimerIdBase)); } // Returns false when there is no host window to hang the timers on, leaving the @@ -491,8 +496,7 @@ bool ScheduleStaggeredTopmost() UINT scheduled = 0; for (UINT_PTR step = 0; step < kTopmostStepCount; ++step) { - if (SetTimer(timer_host, kTopmostStepTimerIdBase + step, delays[step], - SmallWindowTopmostTimerProc) != 0) + if (SetTimer(timer_host, kTopmostStepTimerIdBase + step, delays[step], SmallWindowTopmostTimerProc) != 0) { ++scheduled; } @@ -533,8 +537,7 @@ void TryApplyPendingLazyTopmost(const wchar_t *reason) { FTB_DIAG_LOGF(L"topmost staggered from reason={}: candidate +{}ms, floating-toolbar +{}ms, " L"tray-menu +{}ms", - reason, kCandidateTopmostDelayMs, kFloatingToolbarTopmostDelayMs, - kTrayMenuTopmostDelayMs); + reason, kCandidateTopmostDelayMs, kFloatingToolbarTopmostDelayMs, kTrayMenuTopmostDelayMs); return; } FTB_DIAG_LOGF(L"topmost timers unavailable for reason={}, pinning inline", reason); @@ -626,31 +629,26 @@ void RenderFloatingToolbarState(ICoreWebView2 *webview) const FloatingToolbarItemsConfig &items = GetConfiguredFloatingToolbarItems(); script.append(L"window.setToolbarItem=(id,shown)=>{const item=document.getElementById(id);" L"if(item)item.style.display=shown?'flex':'none';};"); - script.append(items.fullwidth - ? L"window.setToolbarItem('char-width-mode',true);" - : L"window.setToolbarItem('char-width-mode',false);"); - script.append(items.punctuation - ? L"window.setToolbarItem('punctuation-mode',true);" - : L"window.setToolbarItem('punctuation-mode',false);"); - script.append(items.character_set - ? L"window.setToolbarItem('character-set',true);" - : L"window.setToolbarItem('character-set',false);"); - script.append(items.emoji - ? L"window.setToolbarItem('emoji-panel',true);" - : L"window.setToolbarItem('emoji-panel',false);"); - script.append(items.screen_keyboard - ? L"window.setToolbarItem('screen-keyboard',true);" - : L"window.setToolbarItem('screen-keyboard',false);"); - script.append(items.settings - ? L"window.setToolbarItem('settings',true);" - : L"window.setToolbarItem('settings',false);"); + script.append(items.fullwidth ? L"window.setToolbarItem('char-width-mode',true);" + : L"window.setToolbarItem('char-width-mode',false);"); + script.append(items.punctuation ? L"window.setToolbarItem('punctuation-mode',true);" + : L"window.setToolbarItem('punctuation-mode',false);"); + script.append(items.character_set ? L"window.setToolbarItem('character-set',true);" + : L"window.setToolbarItem('character-set',false);"); + script.append(items.emoji ? L"window.setToolbarItem('emoji-panel',true);" + : L"window.setToolbarItem('emoji-panel',false);"); + script.append(items.screen_keyboard ? L"window.setToolbarItem('screen-keyboard',true);" + : L"window.setToolbarItem('screen-keyboard',false);"); + script.append(items.settings ? L"window.setToolbarItem('settings',true);" + : L"window.setToolbarItem('settings',false);"); webview->ExecuteScript(script.c_str(), nullptr); } void SetWebviewMemoryUsageTarget(ComPtr webview, COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL level) { - if (!webview) return; + if (!webview) + return; ComPtr webview23; if (SUCCEEDED(webview.As(&webview23))) @@ -658,7 +656,7 @@ void SetWebviewMemoryUsageTarget(ComPtr webview, COREWEBVIEW2_MEM webview23->put_MemoryUsageTargetLevel(level); } } -} +} // namespace bool EnsureSmallWindowsTopmost(const wchar_t *reason) { @@ -700,8 +698,7 @@ void RaiseTrayMenuAboveSmallWindows(const wchar_t *reason) // Re-assert TOPMOST after FTB (or a peer) was pinned last. A second // HWND_TOPMOST is what actually lifts the menu within the topmost band; // HWND_TOP alone is unreliable here with WS_EX_NOACTIVATE layered hosts. - FTB_DIAG_LOGF(L"menu raise reason={} nav_ready={} {}", reason, menuNavigationReady, - DescribeTrayMenuHostState()); + FTB_DIAG_LOGF(L"menu raise reason={} nav_ready={} {}", reason, menuNavigationReady, DescribeTrayMenuHostState()); constexpr UINT flag = SWP_NOSIZE | SWP_NOMOVE | SWP_NOACTIVATE; SetLastError(0); if (!SetWindowPos(::global_hwnd_menu, HWND_TOPMOST, 0, 0, 0, 0, flag)) @@ -731,6 +728,33 @@ bool IsFloatingToolbarWebviewReady() return floatingToolbarNavigationReady && webviewControllerFtbWnd != nullptr; } +bool IsFloatingToolbarPaintGraceActive() +{ + return floatingToolbarPaintGraceActive; +} + +void BeginFloatingToolbarPaintGrace() +{ + floatingToolbarPaintGraceActive = true; +} + +void EndFloatingToolbarPaintGrace() +{ + floatingToolbarPaintGraceActive = false; +} + +void NotifyFloatingToolbarPageReady() +{ + ReconcileFloatingToolbarVisibilityAfterReady(L"ftb-page-ready"); +} + +void ClearFloatingToolbarNavigationState() +{ + floatingToolbarNavigationReady = false; + floatingToolbarPaintGraceActive = false; + floatingToolbarNavigationRetryUsed = false; +} + bool IsTrayMenuOpenToUser() { return TrayMenuIsOpenToUser(); @@ -818,9 +842,8 @@ void UpdateSmallWindowWebviewVisibility(HWND hwnd, bool visible) if (lowerMemoryWhenHidden) { - SetWebviewMemoryUsageTarget( - webview, - visible ? COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_NORMAL : COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_LOW); + SetWebviewMemoryUsageTarget(webview, visible ? COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_NORMAL + : COREWEBVIEW2_MEMORY_USAGE_TARGET_LEVEL_LOW); } } @@ -910,13 +933,10 @@ void UpdateHtmlContentWithJavaScript(ComPtr webview, const std::w } webview->ExecuteScript( - script.c_str(), - Callback( - [onComplete](HRESULT, LPCWSTR) -> HRESULT { - onComplete(); - return S_OK; - }) - .Get()); + script.c_str(), Callback([onComplete](HRESULT, LPCWSTR) -> HRESULT { + onComplete(); + return S_OK; + }).Get()); } void PrepareCandidateWebViewBoundsForMeasure(HWND hwnd) @@ -1012,11 +1032,9 @@ int PrepareHtmlForWnds() // floating toolbar 窗口 // const bool ftbLight = ResolveConfiguredTheme(GetConfiguredThemeFtb()) == "light"; - std::wstring htmlFtbWnd = - ftbLight ? L"/html/webview2/ftb/default_light.html" : L"/html/webview2/ftb/default.html"; + std::wstring htmlFtbWnd = ftbLight ? L"/html/webview2/ftb/default_light.html" : L"/html/webview2/ftb/default.html"; std::wstring entireHtmlPathFtbWnd = assetPath + htmlFtbWnd; - ::HTMLStringFtbWnd = - ReadHtmlFileWithFallback(entireHtmlPathFtbWnd, assetPath + L"/html/webview2/ftb/default.html"); + ::HTMLStringFtbWnd = ReadHtmlFileWithFallback(entireHtmlPathFtbWnd, assetPath + L"/html/webview2/ftb/default.html"); return 0; } @@ -1043,7 +1061,7 @@ bool ApplyConfiguredUiThemes() } if (webviewFtbWnd && !HTMLStringFtbWnd.empty()) { - floatingToolbarNavigationReady = false; + ClearFloatingToolbarNavigationState(); ok = SUCCEEDED(webviewFtbWnd->NavigateToString(HTMLStringFtbWnd.c_str())) && ok; } if (webviewMenuWnd && !HTMLStringMenuWnd.empty()) @@ -1120,13 +1138,10 @@ void UpdateMeasureContentWithJavaScript(ComPtr webview, const std } webview->ExecuteScript( - script.c_str(), - Callback( - [onComplete](HRESULT, LPCWSTR) -> HRESULT { - onComplete(); - return S_OK; - }) - .Get()); + script.c_str(), Callback([onComplete](HRESULT, LPCWSTR) -> HRESULT { + onComplete(); + return S_OK; + }).Get()); } void UpdateMeasureContentWithJavaScript(ComPtr webview, const std::wstring &newContent) @@ -1272,11 +1287,11 @@ HRESULT OnControllerCreatedCandWnd( // ); // Assets mapping - const HRESULT mappingHr = webview3CandWnd->SetVirtualHostNameToFolderMapping( // - L"candwnd", // - assetPath.c_str(), // - COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_DENY_CORS // - ); // + const HRESULT mappingHr = webview3CandWnd->SetVirtualHostNameToFolderMapping( // + L"candwnd", // + assetPath.c_str(), // + COREWEBVIEW2_HOST_RESOURCE_ACCESS_KIND_DENY_CORS // + ); // (void)0; } @@ -1367,10 +1382,8 @@ HRESULT OnControllerCreatedCandWnd( // const FLOAT scale = static_cast(GetDpiForWindow(hwnd)) / 96.0f; const int current_width = static_cast(window_rect.right - window_rect.left); const int current_height = static_cast(window_rect.bottom - window_rect.top); - const int top_expansion_px = - static_cast(std::ceil(top_expansion * scale)); - const int left_expansion_px = - static_cast(std::ceil(left_expansion * scale)); + const int top_expansion_px = static_cast(std::ceil(top_expansion * scale)); + const int left_expansion_px = static_cast(std::ceil(left_expansion * scale)); SetWindowPos(hwnd, nullptr, window_rect.left - left_expansion_px, window_rect.top - top_expansion_px, (std::max)(current_width, static_cast(std::ceil(width * scale))) + @@ -1388,20 +1401,20 @@ HRESULT OnControllerCreatedCandWnd( // webviewControllerCandWnd->put_Bounds(bounds); if (top_expansion > 0 || left_expansion > 0) { - const std::wstring script = - L"if(window.ApplyContextMenuTopExpansion)" - L"window.ApplyContextMenuTopExpansion(" + - std::to_wstring(top_expansion) + - L");if(window.ApplyContextMenuLeftExpansion)" - L"window.ApplyContextMenuLeftExpansion(" + - std::to_wstring(left_expansion) + L");"; + const std::wstring script = L"if(window.ApplyContextMenuTopExpansion)" + L"window.ApplyContextMenuTopExpansion(" + + std::to_wstring(top_expansion) + + L");if(window.ApplyContextMenuLeftExpansion)" + L"window.ApplyContextMenuLeftExpansion(" + + std::to_wstring(left_expansion) + L");"; webviewCandWnd->ExecuteScript(script.c_str(), nullptr); } } else if (type == "contextMenuClosed") { const std::wstring preedit = GetConfiguredCandidateWindowPreeditStyle() == "empty" - ? std::wstring{} : GetPreeditWithCaretMarker(); + ? std::wstring{} + : GetPreeditWithCaretMarker(); std::wstring measurement = preedit + L"," + Global::CandidateString; InflateMeasureDivCandWnd(measurement, [hwnd]() { FineTuneWindow(hwnd); }); } @@ -1463,15 +1476,16 @@ HRESULT OnControllerCreatedCandWnd( // .Get(), nullptr); - webviewCandWnd->add_ProcessFailed( - Microsoft::WRL::Callback( - [](ICoreWebView2 *, ICoreWebView2ProcessFailedEventArgs *args) -> HRESULT { - COREWEBVIEW2_PROCESS_FAILED_KIND kind = COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED; - const HRESULT hr = args->get_ProcessFailedKind(&kind); - (void)0; - return S_OK; - }).Get(), - nullptr); + webviewCandWnd->add_ProcessFailed(Microsoft::WRL::Callback( + [](ICoreWebView2 *, ICoreWebView2ProcessFailedEventArgs *args) -> HRESULT { + COREWEBVIEW2_PROCESS_FAILED_KIND kind = + COREWEBVIEW2_PROCESS_FAILED_KIND_BROWSER_PROCESS_EXITED; + const HRESULT hr = args->get_ProcessFailedKind(&kind); + (void)0; + return S_OK; + }) + .Get(), + nullptr); OnSmallWindowControllerSettled(S_OK); return S_OK; @@ -1495,7 +1509,7 @@ HRESULT OnEnvironmentCreated(HWND hwnd, HRESULT result, ICoreWebView2Environment } // Create WebView2 controller - const HRESULT hr = env->CreateCoreWebView2Controller( // + const HRESULT hr = env->CreateCoreWebView2Controller( // hwnd, // Microsoft::WRL::Callback( // [hwnd](HRESULT result, // @@ -1621,8 +1635,7 @@ HRESULT OnControllerCreatedMenuWnd( // // Older menu assets already render the handwriting entry but do not // give it an id or native click bridge. Wire it at runtime so existing // installations gain the launcher without replacing their skin. - sender->ExecuteScript( - LR"((() => { + sender->ExecuteScript(LR"((() => { const item = [...document.querySelectorAll('.menu-item')] .find(element => element.textContent.includes('\u624b\u5199\u8bc6\u522b\u677f')); if (item && !item.dataset.nativeHandwritingLauncher) { @@ -1632,7 +1645,7 @@ HRESULT OnControllerCreatedMenuWnd( // }); } })())", - nullptr); + nullptr); SyncMenuFloatingToolbarToggle(); PostMessage(hwnd, WM_REFRESH_MENU_SIZE, 0, 0); return S_OK; @@ -2282,8 +2295,7 @@ HRESULT OnControllerCreatedSettingsWnd( // } else if (path.rfind("general.floating_toolbar_", 0) == 0) { - const std::string item = - path.substr(std::string("general.floating_toolbar_").size()); + const std::string item = path.substr(std::string("general.floating_toolbar_").size()); const bool value = json::value_to(data.at("value")); if (SetConfiguredFloatingToolbarItemEnabled(item, value)) { @@ -2323,6 +2335,14 @@ HRESULT OnControllerCreatedSettingsWnd( // PostSettingsConfig(); } } + else if (path == "utility.date_time_mode") + { + const bool value = json::value_to(data.at("value")); + if (SetConfiguredDateTimeModeEnabled(value)) + { + PostSettingsConfig(); + } + } else if (path == "general.paging_minus_equal") { const bool value = json::value_to(data.at("value")); @@ -2505,12 +2525,11 @@ void SyncMenuFloatingToolbarToggle() // Keep the tray-menu toggle aligned with config.toml so Settings and tray // never drift apart when either side writes general.floating_toolbar. - const wchar_t *script = GetConfiguredFloatingToolbarEnabled() - ? LR"((() => { + const wchar_t *script = GetConfiguredFloatingToolbarEnabled() ? LR"((() => { const toggle = document.getElementById('floatingToggle'); if (toggle) toggle.classList.add('active'); })())" - : LR"((() => { + : LR"((() => { const toggle = document.getElementById('floatingToggle'); if (toggle) toggle.classList.remove('active'); })())"; @@ -2545,67 +2564,69 @@ void PostSettingsConfig() const FloatingToolbarItemsConfig &toolbar = GetConfiguredFloatingToolbarItems(); nlohmann::json payload = { {"type", "configSnapshot"}, - {"data", {{"input", {{"schema", GetConfiguredInputSchemeName()}, - {"character_set", GetConfiguredCharacterSet()}, - {"default_ime_mode", GetConfiguredDefaultImeMode()}, - {"ime_mode_scope", GetConfiguredImeModeScope()}, - {"shuangpin_schema", GetConfiguredShuangpinSchema()}, - {"wubi_schema", GetConfiguredWubiSchema()}, - {"word_to_character", GetConfiguredWordToCharacterEnabled()}, - {"smart_punctuation", GetConfiguredSmartPunctuationEnabled()}}}, - {"general", {{"floating_toolbar", GetConfiguredFloatingToolbarEnabled()}, - {"floating_toolbar_fullwidth", toolbar.fullwidth}, - {"floating_toolbar_punctuation", toolbar.punctuation}, - {"floating_toolbar_character_set", toolbar.character_set}, - {"floating_toolbar_emoji", toolbar.emoji}, - {"floating_toolbar_screen_keyboard", toolbar.screen_keyboard}, - {"floating_toolbar_settings", toolbar.settings}, - {"cn_en_mixed_input", GetConfiguredEnglishCandidatesEnabled()}, - {"cloud_candidates", GetConfiguredCloudCandidatesEnabled()}, - {"paging_minus_equal", GetConfiguredPagingMinusEqualEnabled()}, - {"paging_comma_period", GetConfiguredPagingCommaPeriodEnabled()}, - {"paging_tab", GetConfiguredPagingTabEnabled()}, - {"paging_page_up_down", GetConfiguredPagingPageUpDownEnabled()}, - {"candidate_arrow_navigation", GetConfiguredCandidateArrowNavigationEnabled()}}}, - {"keybindings", {{"switch_language_shift", GetConfiguredSwitchLanguageShiftEnabled()}, - {"switch_language_ctrl", GetConfiguredSwitchLanguageCtrlEnabled()}, - {"switch_language_ctrl_alt_space", - GetConfiguredSwitchLanguageCtrlAltSpaceEnabled()}}}, - {"utility", {{"unicode_mode", GetConfiguredUnicodeModeEnabled()}, - {"quick_phrase", GetConfiguredQuickPhraseEnabled()}}}, - {"appearance", {{"candidate_window_layout", GetConfiguredCandidateWindowLayout()}, - {"candidate_window_preedit_style", GetConfiguredCandidateWindowPreeditStyle()}, - {"tsf_preedit_style", GetConfiguredTsfPreeditStyle()}, - {"theme_mode", GetConfiguredThemeMode()}, - {"theme_settings", GetConfiguredThemeSettings()}, - {"theme_cand", GetConfiguredThemeCand()}, - {"theme_ftb", GetConfiguredThemeFtb()}, - {"theme_menu", GetConfiguredThemeMenu()}, - {"theme_emoji", GetConfiguredThemeEmoji()}, - {"theme_screen_keyboard", GetConfiguredThemeScreenKeyboard()}, - {"theme_handwriting", GetConfiguredThemeHandwriting()}, - {"theme_voice", GetConfiguredThemeVoice()}, - {"page_size", GetConfiguredCandidatePageSize()}, - {"font", GetConfiguredCandidateFont()}, - {"font_css_family", ResolveSystemFontFamilyForCss(GetConfiguredCandidateFont())}, - {"english_font", GetConfiguredCandidateEnglishFont()}, - {"english_font_css_family", - ResolveSystemFontFamilyForCss(GetConfiguredCandidateEnglishFont())}, - {"default_font", GetConfiguredCandidateDefaultFont()}, - {"default_font_css_family", - ResolveSystemFontFamilyForCss(GetConfiguredCandidateDefaultFont())}, - {"font_size", GetConfiguredCandidateFontSize()}, - {"cand_text_color", GetConfiguredCandidateTextColor()}, - {"system_fonts", GetSystemFontFamilies()}}}, - {"helpcode", - {{"shuangpin_helpcode", GetConfiguredShuangpinHelpcodeEnabled()}, - {"shuangpin_helpcode_schema", GetConfiguredShuangpinHelpcodeSchema()}, - {"quanpin_helpcode", GetConfiguredQuanpinHelpcodeEnabled()}, - {"quanpin_helpcode_schema", GetConfiguredQuanpinHelpcodeSchema()}, - {"show_sp_helpcode_in_candidate_window", - GetConfiguredShowShuangpinHelpcodeInCandidateWindow()}, - {"show_qp_helpcode_in_candidate_window", - GetConfiguredShowQuanpinHelpcodeInCandidateWindow()}}}}}}; + {"data", + {{"input", + {{"schema", GetConfiguredInputSchemeName()}, + {"character_set", GetConfiguredCharacterSet()}, + {"default_ime_mode", GetConfiguredDefaultImeMode()}, + {"ime_mode_scope", GetConfiguredImeModeScope()}, + {"shuangpin_schema", GetConfiguredShuangpinSchema()}, + {"wubi_schema", GetConfiguredWubiSchema()}, + {"word_to_character", GetConfiguredWordToCharacterEnabled()}, + {"smart_punctuation", GetConfiguredSmartPunctuationEnabled()}}}, + {"general", + {{"floating_toolbar", GetConfiguredFloatingToolbarEnabled()}, + {"floating_toolbar_fullwidth", toolbar.fullwidth}, + {"floating_toolbar_punctuation", toolbar.punctuation}, + {"floating_toolbar_character_set", toolbar.character_set}, + {"floating_toolbar_emoji", toolbar.emoji}, + {"floating_toolbar_screen_keyboard", toolbar.screen_keyboard}, + {"floating_toolbar_settings", toolbar.settings}, + {"cn_en_mixed_input", GetConfiguredEnglishCandidatesEnabled()}, + {"cloud_candidates", GetConfiguredCloudCandidatesEnabled()}, + {"paging_minus_equal", GetConfiguredPagingMinusEqualEnabled()}, + {"paging_comma_period", GetConfiguredPagingCommaPeriodEnabled()}, + {"paging_tab", GetConfiguredPagingTabEnabled()}, + {"paging_page_up_down", GetConfiguredPagingPageUpDownEnabled()}, + {"candidate_arrow_navigation", GetConfiguredCandidateArrowNavigationEnabled()}}}, + {"keybindings", + {{"switch_language_shift", GetConfiguredSwitchLanguageShiftEnabled()}, + {"switch_language_ctrl", GetConfiguredSwitchLanguageCtrlEnabled()}, + {"switch_language_ctrl_alt_space", GetConfiguredSwitchLanguageCtrlAltSpaceEnabled()}}}, + {"utility", + {{"unicode_mode", GetConfiguredUnicodeModeEnabled()}, + {"quick_phrase", GetConfiguredQuickPhraseEnabled()}, + {"date_time_mode", GetConfiguredDateTimeModeEnabled()}}}, + {"appearance", + {{"candidate_window_layout", GetConfiguredCandidateWindowLayout()}, + {"candidate_window_preedit_style", GetConfiguredCandidateWindowPreeditStyle()}, + {"tsf_preedit_style", GetConfiguredTsfPreeditStyle()}, + {"theme_mode", GetConfiguredThemeMode()}, + {"theme_settings", GetConfiguredThemeSettings()}, + {"theme_cand", GetConfiguredThemeCand()}, + {"theme_ftb", GetConfiguredThemeFtb()}, + {"theme_menu", GetConfiguredThemeMenu()}, + {"theme_emoji", GetConfiguredThemeEmoji()}, + {"theme_screen_keyboard", GetConfiguredThemeScreenKeyboard()}, + {"theme_handwriting", GetConfiguredThemeHandwriting()}, + {"theme_voice", GetConfiguredThemeVoice()}, + {"page_size", GetConfiguredCandidatePageSize()}, + {"font", GetConfiguredCandidateFont()}, + {"font_css_family", ResolveSystemFontFamilyForCss(GetConfiguredCandidateFont())}, + {"english_font", GetConfiguredCandidateEnglishFont()}, + {"english_font_css_family", ResolveSystemFontFamilyForCss(GetConfiguredCandidateEnglishFont())}, + {"default_font", GetConfiguredCandidateDefaultFont()}, + {"default_font_css_family", ResolveSystemFontFamilyForCss(GetConfiguredCandidateDefaultFont())}, + {"font_size", GetConfiguredCandidateFontSize()}, + {"cand_text_color", GetConfiguredCandidateTextColor()}, + {"system_fonts", GetSystemFontFamilies()}}}, + {"helpcode", + {{"shuangpin_helpcode", GetConfiguredShuangpinHelpcodeEnabled()}, + {"shuangpin_helpcode_schema", GetConfiguredShuangpinHelpcodeSchema()}, + {"quanpin_helpcode", GetConfiguredQuanpinHelpcodeEnabled()}, + {"quanpin_helpcode_schema", GetConfiguredQuanpinHelpcodeSchema()}, + {"show_sp_helpcode_in_candidate_window", GetConfiguredShowShuangpinHelpcodeInCandidateWindow()}, + {"show_qp_helpcode_in_candidate_window", GetConfiguredShowQuanpinHelpcodeInCandidateWindow()}}}}}}; const std::wstring message = string_to_wstring(payload.dump()); ::webviewSettingsWnd->PostWebMessageAsJson(message.c_str()); } @@ -2673,8 +2694,8 @@ HRESULT OnControllerCreatedFtbWnd( // // warmup state: visible to WebView2, invisible to the user. DWORD hostCloaked = 0; DwmGetWindowAttribute(hwnd, DWMWA_CLOAKED, &hostCloaked, sizeof(hostCloaked)); - FTB_DIAG_LOGF(L"ftb controller created host_visible={} host_cloaked={}", - IsWindowVisible(hwnd) != FALSE, hostCloaked != 0); + FTB_DIAG_LOGF(L"ftb controller created host_visible={} host_cloaked={}", IsWindowVisible(hwnd) != FALSE, + hostCloaked != 0); UpdateSmallWindowWebviewVisibility(hwnd, IsWindowVisible(hwnd) != FALSE); // Configure webviewFtbWindow settings @@ -2735,7 +2756,7 @@ HRESULT OnControllerCreatedFtbWnd( // // State notifications can arrive before the controller exists or while // NavigateToString is still loading. Only render after a successful // navigation, then replay the complete cached state in one operation. - floatingToolbarNavigationReady = false; + ClearFloatingToolbarNavigationState(); EventRegistrationToken navigationCompletedToken{}; const HRESULT navigationCompletedResult = webviewFtbWnd->add_NavigationCompleted( Microsoft::WRL::Callback( @@ -2750,13 +2771,27 @@ HRESULT OnControllerCreatedFtbWnd( // NotifySmallWindowNavigationReady(floatingToolbarNavigationReady, L"floating-toolbar"); RenderFloatingToolbarState(sender); ApplyConfiguredFloatingToolbarSize(); - // Host is warmed up cloaked. Uncloak (or hide) only after - // the first navigation, when there is something to paint. + // Show first so a cold user-data folder can finish painting; + // a short timer then hides or keeps it based on real state. ApplyConfiguredFloatingToolbarVisibility(L"ftb-navigation-completed"); } else { - (void)0; + // Cold user-data bring-up can fail the first NavigateToString. + // Retry once against the same controller instead of leaving + // the host permanently cloaked with webview_ready=false. + if (!floatingToolbarNavigationRetryUsed && !::HTMLStringFtbWnd.empty()) + { + floatingToolbarNavigationRetryUsed = true; + floatingToolbarNavigationReady = false; + floatingToolbarPaintGraceActive = false; + FTB_DIAG_LOGF(L"ftb navigation failed; retrying NavigateToString once"); + sender->NavigateToString(::HTMLStringFtbWnd.c_str()); + } + else + { + FTB_DIAG_LOGF(L"ftb navigation failed permanently after retry"); + } } return S_OK; }) @@ -2801,6 +2836,10 @@ HRESULT OnControllerCreatedFtbWnd( // ReleaseCapture(); PostMessage(hwnd, WM_NCLBUTTONDOWN, HTCAPTION, 0); } + else if (type == "ready") + { + NotifyFloatingToolbarPageReady(); + } else if (type == "changeIMEMode") { std::string mode = json::value_to(val.at("data")); @@ -2810,16 +2849,16 @@ HRESULT OnControllerCreatedFtbWnd( // #ifdef FANY_DEBUG (void)0; #endif - SendToTsfWorkerThreadViaNamedpipe(Global::DataFromServerMsgTypeToTsfWorkerThread::SwitchToCn, - L""); + SendToTsfWorkerThreadViaNamedpipe( + Global::DataFromServerMsgTypeToTsfWorkerThread::SwitchToCn, L""); } else if (mode == "en") // Change to EN { #ifdef FANY_DEBUG (void)0; #endif - SendToTsfWorkerThreadViaNamedpipe(Global::DataFromServerMsgTypeToTsfWorkerThread::SwitchToEn, - L""); + SendToTsfWorkerThreadViaNamedpipe( + Global::DataFromServerMsgTypeToTsfWorkerThread::SwitchToEn, L""); } } else if (type == "changeCharMode") @@ -2864,9 +2903,8 @@ HRESULT OnControllerCreatedFtbWnd( // } else if (type == "changeCharacterSet") { - const std::string next = GetConfiguredCharacterSet() == "traditional" - ? "simplified" - : "traditional"; + const std::string next = + GetConfiguredCharacterSet() == "traditional" ? "simplified" : "traditional"; if (SetConfiguredCharacterSet(next)) { RenderFloatingToolbarState(sender); @@ -2977,8 +3015,8 @@ bool PrepareTrayMenuWebviewForShow() return false; } - FTB_DIAG_LOGF(L"menu prepare: no controller yet, init_state={} attempts={}", - static_cast(smallWindowInitState), smallWindowInitAttempts); + FTB_DIAG_LOGF(L"menu prepare: no controller yet, init_state={} attempts={}", static_cast(smallWindowInitState), + smallWindowInitAttempts); if (smallWindowInitState != SmallWindowInitState::InProgress) { // An explicit right-click is a fresh user intent: reset the attempt diff --git a/server/src/webview2/windows_webview2.h b/server/src/webview2/windows_webview2.h index c23bc007..472d7293 100644 --- a/server/src/webview2/windows_webview2.h +++ b/server/src/webview2/windows_webview2.h @@ -96,6 +96,13 @@ bool AreSmallWindowWebviewsReady(); // and can actually paint. Its host must stay on-monitor and "visible" until // then, otherwise WebView2 never finishes raster setup for it. bool IsFloatingToolbarWebviewReady(); +void NotifyFloatingToolbarPageReady(); +// True while the post-navigation paint grace is active: the host is kept shown +// briefly after NavigationCompleted, then visibility is reconciled for real. +bool IsFloatingToolbarPaintGraceActive(); +void BeginFloatingToolbarPaintGrace(); +void EndFloatingToolbarPaintGrace(); +void ClearFloatingToolbarNavigationState(); // True only when the tray menu is actually open in front of the user: its // WebView2 has painted at least once and the host is neither hidden nor still // DWM-cloaked for warmup. IsWindowVisible() alone cannot answer this, because diff --git a/server/src/window/floating_toolbar_visibility_policy.h b/server/src/window/floating_toolbar_visibility_policy.h index ee61e430..e3106ba9 100644 --- a/server/src/window/floating_toolbar_visibility_policy.h +++ b/server/src/window/floating_toolbar_visibility_policy.h @@ -12,4 +12,12 @@ constexpr bool ShouldShowFloatingToolbar(bool configured_enabled, { return configured_enabled && !fullscreen && ime_active; } + +// After the first NavigationCompleted, keep the host shown until the page +// reports ready (or a fallback timeout). Hide decisions are deferred until +// then, then re-evaluated for real. +constexpr bool ShouldDeferFloatingToolbarHide(bool paint_grace_active) +{ + return paint_grace_active; +} } // namespace FanyImeUi diff --git a/server/src/window/ime_windows.cpp b/server/src/window/ime_windows.cpp index c016a6d6..92c62e18 100644 --- a/server/src/window/ime_windows.cpp +++ b/server/src/window/ime_windows.cpp @@ -34,6 +34,10 @@ constexpr UINT_PTR TIMER_ID_MOVE_WEBVIEW_SETTINGS = 3; constexpr UINT_PTR TIMER_ID_MOVE_WEBVIEW_FTB = 4; constexpr UINT_PTR TIMER_ID_CONFIG_SYNC = 7; constexpr UINT_PTR TIMER_ID_SETTINGS_ACTIVATION_RETRY = 8; +constexpr UINT_PTR TIMER_ID_FTB_VISIBILITY_RECONCILE = 9; +// Long enough fallback when the page never posts ready (old HTML / failed JS). +// Page-ready normally ends the grace earlier; until then the toolbar stays shown. +constexpr UINT kFloatingToolbarPaintGraceMs = 6000; constexpr UINT WM_ACTIVATE_SETTINGS_WINDOW = WM_APP + 110; int FineTuneWindow(HWND hwnd); @@ -537,6 +541,11 @@ void ApplyConfiguredFloatingToolbarVisibility(const wchar_t *reason) const bool should_show = FanyImeUi::ShouldShowFloatingToolbar(configured, fullscreen, g_is_ime_active); const bool is_visible = IsWindowVisible(::global_hwnd_ftb) != FALSE; + const bool paint_grace = IsFloatingToolbarPaintGraceActive(); + // First successful navigation: show briefly so cold WebView2 can paint, then + // reconcile. Do not jump straight to SW_HIDE when IME is not active yet. + const bool start_paint_grace = + reason && wcscmp(reason, L"ftb-navigation-completed") == 0 && IsFloatingToolbarWebviewReady(); // Each host-state snapshot crosses into DWM and walks the child windows, and // a burst of queued activations can drive hundreds of applies through here on @@ -550,9 +559,9 @@ void ApplyConfiguredFloatingToolbarVisibility(const wchar_t *reason) // matter more than IsWindowVisible once the host is warmed up. std::wstring decision = fmt::format( L"apply reason={} active_client={} ime_active={} configured={} fullscreen={} " - L"should_show={} was_visible={} was_cloaked={} webview_ready={}", + L"should_show={} was_visible={} was_cloaked={} webview_ready={} paint_grace={}", reason, active_client, g_is_ime_active, configured, fullscreen, should_show, is_visible, - IsHostWindowCloaked(::global_hwnd_ftb), IsFloatingToolbarWebviewReady()); + IsHostWindowCloaked(::global_hwnd_ftb), IsFloatingToolbarWebviewReady(), paint_grace); // Applies are confined to the UI thread, so plain statics suffice. static std::wstring last_decision; @@ -592,7 +601,8 @@ void ApplyConfiguredFloatingToolbarVisibility(const wchar_t *reason) } } state_after{reason, trace}; - if (should_show) + const bool force_show = should_show || start_paint_grace || paint_grace; + if (force_show) { // Keep the dragged position across IME activate / config / fullscreen // exit. Only resize for the current DPI. @@ -619,6 +629,16 @@ void ApplyConfiguredFloatingToolbarVisibility(const wchar_t *reason) { SetHostWindowCloaked(::global_hwnd_ftb, false); } + if (start_paint_grace) + { + BeginFloatingToolbarPaintGrace(); + KillTimer(::global_hwnd_ftb, TIMER_ID_FTB_VISIBILITY_RECONCILE); + // Fallback only: page posts type=ready to reconcile earlier. + SetTimer(::global_hwnd_ftb, TIMER_ID_FTB_VISIBILITY_RECONCILE, kFloatingToolbarPaintGraceMs, + nullptr); + FTB_DIAG_LOGF(L"ftb paint grace started; waiting for page ready (fallback {}ms)", + kFloatingToolbarPaintGraceMs); + } } else if (is_visible) { @@ -626,9 +646,26 @@ void ApplyConfiguredFloatingToolbarVisibility(const wchar_t *reason) } } +void ReconcileFloatingToolbarVisibilityAfterReady(const wchar_t *reason) +{ + if (!::global_hwnd_ftb) + { + return; + } + KillTimer(::global_hwnd_ftb, TIMER_ID_FTB_VISIBILITY_RECONCILE); + if (IsFloatingToolbarPaintGraceActive()) + { + EndFloatingToolbarPaintGrace(); + FTB_DIAG_LOGF(L"ftb paint grace ended reason={}", reason ? reason : L"unspecified"); + } + ApplyConfiguredFloatingToolbarVisibility(reason ? reason : L"ftb-ready"); +} + // Hiding the host before its WebView2 has painted once is what leaves the // toolbar permanently blank, so during warmup the hide is expressed as a cloak: -// invisible to the user, still on-monitor and "visible" to WebView2. +// invisible to the user, still on-monitor and "visible" to WebView2. Until the +// page reports ready (or the fallback timer fires), skip hide so first paint can +// finish and the real visibility decision can be applied afterwards. void HideFloatingToolbarHost() { if (!::global_hwnd_ftb) @@ -637,6 +674,11 @@ void HideFloatingToolbarHost() } // The fullscreen hook calls this directly, so without a line here the trace // shows a toolbar that became hidden with no apply to account for it. + if (FanyImeUi::ShouldDeferFloatingToolbarHide(IsFloatingToolbarPaintGraceActive())) + { + FTB_DIAG_LOGF(L"hide host skipped: paint grace active"); + return; + } FTB_DIAG_LOGF(L"hide host webview_ready={} (cloak-only while warming up)", IsFloatingToolbarWebviewReady()); if (!IsFloatingToolbarWebviewReady()) @@ -1992,6 +2034,12 @@ LRESULT CALLBACK WndProcFtbWindow(HWND hwnd, UINT message, WPARAM wParam, LPARAM PlaceFloatingToolbarOnScreen(hwnd); break; } + if (wParam == TIMER_ID_FTB_VISIBILITY_RECONCILE) + { + KillTimer(hwnd, TIMER_ID_FTB_VISIBILITY_RECONCILE); + ReconcileFloatingToolbarVisibilityAfterReady(L"ftb-ready-fallback-timeout"); + break; + } break; } default: { diff --git a/server/src/window/ime_windows.h b/server/src/window/ime_windows.h index 4aa1b640..fe25b08e 100644 --- a/server/src/window/ime_windows.h +++ b/server/src/window/ime_windows.h @@ -29,6 +29,9 @@ void RequestSettingsWindowActivation(HWND hwnd); // whole class of bugs here is "nothing ever called this", so the caller has to // be recoverable from the log. void ApplyConfiguredFloatingToolbarVisibility(const wchar_t *reason = L"unspecified"); +// End the post-navigation paint grace (page-ready or fallback timeout) and apply +// the real show/hide decision. Until this runs, the toolbar stays shown. +void ReconcileFloatingToolbarVisibilityAfterReady(const wchar_t *reason = L"ftb-ready"); // Use instead of ShowWindow(SW_HIDE) on the toolbar host: hiding it before its // WebView2 has painted once permanently breaks the toolbar's rendering. void HideFloatingToolbarHost(); diff --git a/server/tests/CMakeLists.txt b/server/tests/CMakeLists.txt index a4770d87..ffc72fc0 100644 --- a/server/tests/CMakeLists.txt +++ b/server/tests/CMakeLists.txt @@ -2,6 +2,8 @@ set(TEST_EXECUTABLE_NAME "MetasequoiaImeServerTests") set(TEST_SOURCE_FILES "${CMAKE_CURRENT_SOURCE_DIR}/src/main.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/test_date_time_query.cpp" + "${CMAKE_SOURCE_DIR}/src/date-time/date_time_query.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_shuangpin_scheme.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_quanpin_scheme.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_wubi.cpp" diff --git a/server/tests/src/test_date_time_query.cpp b/server/tests/src/test_date_time_query.cpp new file mode 100644 index 00000000..321a90d2 --- /dev/null +++ b/server/tests/src/test_date_time_query.cpp @@ -0,0 +1,57 @@ +#include "tests/includes/test_framework.h" +#include "date-time/date_time_query.h" + +#include + +namespace +{ +SYSTEMTIME SampleTime() +{ + SYSTEMTIME value = {}; + value.wYear = 2026; + value.wMonth = 8; + value.wDay = 8; + value.wDayOfWeek = 6; + value.wHour = 15; + value.wMinute = 4; + value.wSecond = 5; + return value; +} +} // namespace + +TEST_CASE(date_time_query_accepts_all_date_wake_words) +{ + const SYSTEMTIME now = SampleTime(); + for (const char *keyword : std::array{"rq", "riqi", "date"}) + { + const auto results = DateTimeQuery::Query(keyword, &now); + REQUIRE_EQ(results.size(), static_cast(8)); + REQUIRE_EQ(results[0].word, std::string("2026年8月8日")); + REQUIRE_EQ(results[1].word, std::string("2026-08-08")); + REQUIRE_EQ(results[5].word, std::string("二〇二六年八月八日")); + REQUIRE_EQ(results[7].word, std::string("2026年8月8日 星期六")); + REQUIRE(results[0].source == CandidateSource::Generated); + } +} + +TEST_CASE(date_time_query_accepts_all_time_wake_words) +{ + const SYSTEMTIME now = SampleTime(); + for (const char *keyword : std::array{"sj", "shijian", "time"}) + { + const auto results = DateTimeQuery::Query(keyword, &now); + REQUIRE_EQ(results.size(), static_cast(8)); + REQUIRE_EQ(results[0].word, std::string("15:04:05")); + REQUIRE_EQ(results[1].word, std::string("15:04")); + REQUIRE_EQ(results[4].word, std::string("下午3:04:05")); + REQUIRE_EQ(results[7].word, std::string("2026-08-08 15:04:05")); + } +} + +TEST_CASE(date_time_query_rejects_unknown_words_and_honors_limit) +{ + const SYSTEMTIME now = SampleTime(); + REQUIRE(DateTimeQuery::Query("today", &now).empty()); + REQUIRE(DateTimeQuery::Query("rq", &now, 0).empty()); + REQUIRE_EQ(DateTimeQuery::Query("rq", &now, 3).size(), static_cast(3)); +} diff --git a/server/tests/src/test_floating_toolbar_visibility_policy.cpp b/server/tests/src/test_floating_toolbar_visibility_policy.cpp index 56d2b4d7..1d03475e 100644 --- a/server/tests/src/test_floating_toolbar_visibility_policy.cpp +++ b/server/tests/src/test_floating_toolbar_visibility_policy.cpp @@ -9,3 +9,9 @@ TEST_CASE(floating_toolbar_visibility_requires_active_ime_preference_and_nonfull REQUIRE(!FanyImeUi::ShouldShowFloatingToolbar(true, false, false)); REQUIRE(!FanyImeUi::ShouldShowFloatingToolbar(false, true, false)); } + +TEST_CASE(floating_toolbar_defers_hide_during_paint_grace) +{ + REQUIRE(FanyImeUi::ShouldDeferFloatingToolbarHide(true)); + REQUIRE(!FanyImeUi::ShouldDeferFloatingToolbarHide(false)); +}