From 06bba15d4f2738d3e93c34819c7bad8813a137c5 Mon Sep 17 00:00:00 2001 From: Evan Hu Date: Sun, 6 Sep 2026 03:31:40 +0900 Subject: [PATCH] fix(server): keep settings I/O off UI thread and bound dictionary rendering --- product-lock.json | 2 +- server/src/settings/dictionary_manager.cpp | 72 ++++----- server/src/settings/dictionary_page.h | 47 ++++++ server/src/settings/serial_task_queue.h | 76 +++++++++ server/src/settings/settings_app.cpp | 151 ++++++++++++++---- server/src/settings/settings_splash.cpp | 20 +-- server/src/settings/settings_splash.h | 3 +- server/tests/CMakeLists.txt | 1 + .../tests/src/test_settings_performance.cpp | 83 ++++++++++ .../ime-settings/src/modules/config-sync.ts | 55 ++++--- .../settings/ime-settings/src/modules/dict.ts | 41 +++-- .../ime-settings/src/modules/sidebar.ts | 1 - .../settings/ime-settings/src/modules/skin.ts | 56 ++++--- .../src/modules/tools-settings.ts | 38 +++-- .../ime-settings/src/styles/modules/dict.css | 3 + .../src/utils/dictionary-pagination.ts | 35 ++++ ui-html/webview2/shared/messages.ts | 6 +- ui-html/webview2/shared/schema.js | 2 +- vendor/MetasequoiaImeEngine | 2 +- 19 files changed, 546 insertions(+), 148 deletions(-) create mode 100644 server/src/settings/dictionary_page.h create mode 100644 server/src/settings/serial_task_queue.h create mode 100644 server/tests/src/test_settings_performance.cpp create mode 100644 ui-html/webview2/settings/ime-settings/src/utils/dictionary-pagination.ts diff --git a/product-lock.json b/product-lock.json index 43656978..1d5dc563 100644 --- a/product-lock.json +++ b/product-lock.json @@ -7,7 +7,7 @@ }, "engine": { "repository": "metasequoiaime/MSIME-Engine", - "commit": "6bd22549d6e275e401052c55cca6c57fe8408819" + "commit": "403b4a4d72b0ab5110155aeb4a1f0c170730fc7b" } }, "dictionary": { diff --git a/server/src/settings/dictionary_manager.cpp b/server/src/settings/dictionary_manager.cpp index 085cacc6..4d901dda 100644 --- a/server/src/settings/dictionary_manager.cpp +++ b/server/src/settings/dictionary_manager.cpp @@ -1,4 +1,6 @@ #include "settings/dictionary_manager.h" +#include "settings/dictionary_page.h" +#include #include "config/ime_config.h" #include "defines/defines.h" @@ -321,7 +323,21 @@ bool ValidateChineseEntry(const std::string &mode, const std::string &code, cons return true; } -json::object QueryChinese(const std::string &mode, const std::string &search) +bool EnsureEnglishSchema() +{ + // Installed dictionaries keep their schema for the lifetime of Settings. + // Do not open a write transaction for every read-only search. + static std::mutex mutex; + static std::set initialized; + std::lock_guard lock(mutex); + const auto path = CommonUtils::get_ime_data_path() + "\\english.db"; + if (initialized.count(path)) return true; + if (!EnglishDictionary::ensure_schema(path)) return false; + initialized.insert(path); + return true; +} + +json::object QueryChinese(const std::string &mode, const std::string &search, const json::object &request) { std::string error; Db db = OpenDatabase("msime.db", error); @@ -334,14 +350,9 @@ json::object QueryChinese(const std::string &mode, const std::string &search) if (!normalized.empty()) { const std::string table = quanpin::build_table_name(segments); - Stmt stmt = Prepare(db.get(), "SELECT key,value,weight FROM \"" + table + "\" WHERE key=?1 ORDER BY weight DESC", error); + Stmt stmt = Prepare(db.get(), "SELECT key,value,weight FROM \"" + table + "\" WHERE key=?1 ORDER BY weight DESC,value" + Paging::Sql(request), error); if (!stmt || !BindText(stmt.get(), 1, normalized)) return Result(false, "查询失败:" + error); - while (sqlite3_step(stmt.get()) == SQLITE_ROW) - { - rows.push_back({{"code", reinterpret_cast(sqlite3_column_text(stmt.get(), 0))}, - {"word", reinterpret_cast(sqlite3_column_text(stmt.get(), 1))}, - {"weight", sqlite3_column_int(stmt.get(), 2)}}); - } + return Paging::Read(stmt.get(), request); } json::object result = Result(true, rows.empty() ? "没有找到词条" : "查询成功"); result["rows"] = std::move(rows); @@ -665,7 +676,7 @@ json::object ImportEnglish(const json::object &request) return Result(false, "文件内容为空"); std::string error; - (void)EnglishDictionary::ensure_schema(CommonUtils::get_ime_data_path() + "\\english.db"); + if (!EnsureEnglishSchema()) return Result(false, "英文词库初始化失败"); Db db = OpenDatabase("english.db", error); if (!db) return Result(false, "打开英文词库失败:" + error); Stmt insert = Prepare(db.get(), @@ -795,22 +806,21 @@ json::object HandleEnglish(const json::object &request) const int weight = (std::max)(0, IntValue(request, "weight", 10)); std::transform(word.begin(), word.end(), word.begin(), [](unsigned char ch) { return static_cast(std::tolower(ch)); }); std::string error; - (void)EnglishDictionary::ensure_schema(CommonUtils::get_ime_data_path() + "\\english.db"); + if (!EnsureEnglishSchema()) return Result(false, "英文词库初始化失败"); Db db = OpenDatabase("english.db", error); if (!db) return Result(false, "打开英文词库失败:" + error); if (action == "query") { - Stmt stmt = Prepare(db.get(), "SELECT word,display,weight FROM english_words WHERE word LIKE ?1 " - "ORDER BY CASE WHEN word=?2 THEN 0 ELSE 1 END,weight DESC,length(word),word,display", error); - const std::string pattern = word + "%"; - if (!stmt || !BindText(stmt.get(), 1, pattern) || !BindText(stmt.get(), 2, word)) return Result(false, "查询失败"); - json::array rows; - while (sqlite3_step(stmt.get()) == SQLITE_ROW) - rows.push_back({{"word", reinterpret_cast(sqlite3_column_text(stmt.get(), 0))}, - {"display", reinterpret_cast(sqlite3_column_text(stmt.get(), 1))}, - {"weight", sqlite3_column_int(stmt.get(), 2)}}); - json::object result = Result(true, rows.empty() ? "没有找到词条" : "查询成功"); result["rows"] = std::move(rows); return result; + if (!IsAsciiWord(word)) return Result(false, "请输入英文单词前缀"); + std::string upper = word; + ++upper.back(); // ASCII prefix upper bound, compatible with the BINARY primary key. + Stmt stmt = Prepare(db.get(), "SELECT word,display,weight FROM english_words WHERE word>=?1 AND word= 'a' && ch <= 'z'; })) return Result(false, "请输入合法的五笔编码"); - Stmt stmt = Prepare(db.get(), "SELECT key,value,weight FROM wubi86 WHERE key LIKE ?1 ORDER BY weight DESC", error); + Stmt stmt = Prepare(db.get(), "SELECT key,value,weight FROM wubi86 WHERE key LIKE ?1 ORDER BY weight DESC,key,value" + Paging::Sql(request), error); if (!stmt || !BindText(stmt.get(), 1, code + "%")) return Result(false, "查询失败:" + error); - json::array rows; - while (sqlite3_step(stmt.get()) == SQLITE_ROW) - rows.push_back({{"code", reinterpret_cast(sqlite3_column_text(stmt.get(), 0))}, - {"word", reinterpret_cast(sqlite3_column_text(stmt.get(), 1))}, - {"weight", sqlite3_column_int(stmt.get(), 2)}}); - json::object result = Result(true, rows.empty() ? "没有找到词条" : "查询成功"); result["rows"] = std::move(rows); return result; + return Paging::Read(stmt.get(), request); } if (code.empty() || word.empty() || !std::all_of(code.begin(), code.end(), [](unsigned char ch) { return ch >= 'a' && ch <= 'z'; })) return Result(false, "五笔编码和词条不能为空,编码只能包含英文字母"); @@ -1192,16 +1197,9 @@ json::object HandleQuickPhrase(const json::object &request) { if (!code.empty() && !valid_code(code)) return Result(false, "编码只能包含英文字母"); Stmt stmt = Prepare(db.get(), "SELECT key,value,weight FROM quick_parases WHERE key LIKE ?1 " - "ORDER BY weight DESC,key,value", error); + "ORDER BY weight DESC,key,value" + Paging::Sql(request), error); if (!stmt || !BindText(stmt.get(), 1, code + "%")) return Result(false, "查询失败:" + error); - json::array rows; - while (sqlite3_step(stmt.get()) == SQLITE_ROW) - rows.push_back({{"code", reinterpret_cast(sqlite3_column_text(stmt.get(), 0))}, - {"word", reinterpret_cast(sqlite3_column_text(stmt.get(), 1))}, - {"weight", sqlite3_column_int(stmt.get(), 2)}}); - json::object result = Result(true, rows.empty() ? "没有找到快捷短语" : "查询成功"); - result["rows"] = std::move(rows); - return result; + return Paging::Read(stmt.get(), request); } if (!valid_code(code) || phrase.empty()) return Result(false, "编码只能包含英文字母,短语不能为空"); @@ -1266,7 +1264,7 @@ json::object HandleRequest(const json::object &request) if (dictionary == "wubi") return HandleWubi(request); if (dictionary == "quick") return HandleQuickPhrase(request); if (dictionary != "quanpin") return Result(false, "未知词库"); - if (action == "query") return QueryChinese(dictionary, StringValue(request, "code")); + if (action == "query") return QueryChinese(dictionary, StringValue(request, "code"), request); if (action == "import") return ImportChinese(request); return MutateChinese(request); } diff --git a/server/src/settings/dictionary_page.h b/server/src/settings/dictionary_page.h new file mode 100644 index 00000000..4058337d --- /dev/null +++ b/server/src/settings/dictionary_page.h @@ -0,0 +1,47 @@ +#pragma once +#include +#include +#include +#include +#include + +namespace SettingsDictionary::Paging +{ +inline int Integer(const boost::json::object &request, const char *key, int fallback, int maximum) +{ + const auto *value = request.if_contains(key); + if (!value || !value->is_int64()) return fallback; + return static_cast((std::max)(int64_t{0}, (std::min)(value->as_int64(), int64_t{maximum}))); +} +inline int Limit(const boost::json::object &request) +{ + return (std::max)(1, Integer(request, "limit", 200, 200)); +} +inline std::string Sql(const boost::json::object &request) +{ + return " LIMIT " + std::to_string(Limit(request) + 1) + " OFFSET " + + std::to_string(Integer(request, "offset", 0, 2147483647)); +} +inline boost::json::object Read(sqlite3_stmt *statement, const boost::json::object &request, bool english = false) +{ + namespace json = boost::json; + json::array rows; + int status; + while ((status = sqlite3_step(statement)) == SQLITE_ROW) + { + const auto text = [statement](int column) { + const auto *value = sqlite3_column_text(statement, column); + return value ? reinterpret_cast(value) : ""; + }; + rows.push_back({{english ? "word" : "code", text(0)}, {english ? "display" : "word", text(1)}, + {"weight", sqlite3_column_int(statement, 2)}}); + } + if (status != SQLITE_DONE) + return {{"ok", false}, {"message", "查询失败,请重试"}, {"rows", json::array{}}}; + const bool more = rows.size() > static_cast(Limit(request)); + if (more) rows.pop_back(); + return {{"ok", true}, {"message", rows.empty() ? "没有找到词条" : "查询成功"}, + {"rows", std::move(rows)}, {"hasMore", more}, + {"offset", Integer(request, "offset", 0, 2147483647)}}; +} +} // namespace SettingsDictionary::Paging diff --git a/server/src/settings/serial_task_queue.h b/server/src/settings/serial_task_queue.h new file mode 100644 index 00000000..0aacbaae --- /dev/null +++ b/server/src/settings/serial_task_queue.h @@ -0,0 +1,76 @@ +#pragma once +#include +#include +#include +#include +#include +#include + +// Work runs serially on one worker. Completions are explicitly drained by the +// owner thread. Stop drains accepted work; no detached threads outlive the owner. +class SerialTaskQueue +{ +public: + using Completion = std::function; + using Task = std::function; + SerialTaskQueue(Completion notify, Completion error, Completion cleanup = {}) + : notify_(std::move(notify)), error_(std::move(error)), cleanup_(std::move(cleanup)), + thread_([this] { Run(); }) {} + ~SerialTaskQueue() { Stop(); thread_.join(); } + bool Submit(Task task) + { + std::lock_guard lock(mutex_); + if (stopping_) return false; + tasks_.push_back(std::move(task)); + ready_.notify_one(); + return true; + } + void Stop() + { + std::lock_guard lock(mutex_); + stopping_ = true; + ready_.notify_one(); + } + bool Stopped() + { + std::lock_guard lock(mutex_); + return stopped_; + } + void Drain(bool deliver = true) + { + std::deque completed; + { std::lock_guard lock(mutex_); completed.swap(completed_); } + if (deliver) for (auto &completion : completed) completion(); + } +private: + void Run() + { + for (;;) + { + Task task; + { + std::unique_lock lock(mutex_); + ready_.wait(lock, [this] { return stopping_ || !tasks_.empty(); }); + if (tasks_.empty()) break; + task = std::move(tasks_.front()); tasks_.pop_front(); + } + Completion result; + try { result = task(); } catch (...) { result = error_; } + if (result) + { + { std::lock_guard lock(mutex_); completed_.push_back(std::move(result)); } + notify_(); + } + } + if (cleanup_) cleanup_(); + { std::lock_guard lock(mutex_); stopped_ = true; } + notify_(); + } + Completion notify_, error_, cleanup_; + std::mutex mutex_; + std::condition_variable ready_; + std::deque tasks_; + std::deque completed_; + bool stopping_ = false, stopped_ = false; + std::thread thread_; +}; diff --git a/server/src/settings/settings_app.cpp b/server/src/settings/settings_app.cpp index 5d9be688..410b4ae4 100644 --- a/server/src/settings/settings_app.cpp +++ b/server/src/settings/settings_app.cpp @@ -5,6 +5,9 @@ #include "settings/settings_launcher.h" #include "settings/settings_splash.h" #include "settings/dictionary_manager.h" +#include "settings/serial_task_queue.h" +#include +#include #include "skin/candidate_skin_catalog.h" #include "utils/common_utils.h" #include "utils/single_instance.h" @@ -47,6 +50,7 @@ constexpr wchar_t kSingleInstanceMutex[] = L"Local\\MetasequoiaImeSettings.Singl constexpr UINT kActivateExistingWindow = WM_APP + 1; constexpr UINT kOpenAboutSection = WM_APP + 2; constexpr UINT kScanSkinCatalog = WM_APP + 3; +constexpr UINT kWorkerCompleted = WM_APP + 4; constexpr UINT_PTR kConfigReloadTimer = 1; ComPtr g_controller; @@ -67,6 +71,14 @@ bool g_webview_content_started = false; HWND g_settings_hwnd = nullptr; std::optional g_candidate_skin_catalog; uint64_t g_candidate_skin_catalog_revision = 0; +std::unique_ptr g_worker; +bool g_closing = false; +bool g_reload_pending = false; +bool g_settings_light = false; +std::wstring g_last_config_message; +bool g_worker_com_initialized = false; // worker-only +void CloseSettings(HWND hwnd); + nlohmann::json CandidateColorsToJson(const CandidateSkinCatalog::CandidateColors &colors) { @@ -173,7 +185,7 @@ void ApplyWindowActivationAppearance() void ApplySettingsChromeTheme(HWND hwnd = nullptr) { const HWND target = hwnd ? hwnd : g_settings_hwnd; - const bool light = ResolveConfiguredTheme(GetConfiguredThemeSettings()) == "light"; + const bool light = g_settings_light; if (target) { BOOL dark = light ? FALSE : TRUE; @@ -235,10 +247,8 @@ void ResetTitlebarHoverAfterVisibilityChange() })())JS", nullptr); } -void PostConfig(bool refresh_skin_catalog = false) +std::wstring BuildConfigMessage(bool refresh_skin_catalog) { - if (!g_webview) - return; const VoiceInputConfig &voice = GetConfiguredVoiceInput(); const AiAssistantConfig &ai = GetConfiguredAiAssistant(); @@ -430,9 +440,30 @@ void PostConfig(bool refresh_skin_catalog = false) payload["protocolVersion"] = metasequoia::webview::Version; const std::string serialized = payload.dump(); if (!metasequoia::webview::Validate(json::parse(serialized), "server")) - return; - const std::wstring message = string_to_wstring(serialized); - g_webview->PostWebMessageAsJson(message.c_str()); + throw std::runtime_error("Invalid config snapshot"); + return string_to_wstring(serialized); +} + +SerialTaskQueue::Completion ConfigCompletion(bool refresh_skin_catalog = false) +{ + auto message = BuildConfigMessage(refresh_skin_catalog); + const bool light = ResolveConfiguredTheme(GetConfiguredThemeSettings()) == "light"; + return [message = std::move(message), light] { + g_settings_light = light; + ApplySettingsChromeTheme(); + SettingsSplash::SetTheme(light); + if (g_webview && message != g_last_config_message) + { + if (SUCCEEDED(g_webview->PostWebMessageAsJson(message.c_str()))) + g_last_config_message = message; + } + }; +} + +void PostConfig(bool refresh_skin_catalog = false) +{ + if (!g_worker || g_closing) return; + g_worker->Submit([refresh_skin_catalog] { return ConfigCompletion(refresh_skin_catalog); }); } void PostWindowState(HWND hwnd) @@ -699,6 +730,7 @@ bool CopyTextToClipboard(HWND hwnd, const std::wstring &text) void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args) { + if (g_closing || !g_worker) return; wil::unique_cotaskmem_string raw; if (FAILED(args->TryGetWebMessageAsString(&raw)) || !raw) return; @@ -733,7 +765,7 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args) if (command == "minimize") ShowWindow(hwnd, SW_MINIMIZE); else if (command == "maximize") ShowWindow(hwnd, SW_MAXIMIZE); else if (command == "restore") ShowWindow(hwnd, SW_RESTORE); - else if (command == "close") DestroyWindow(hwnd); + else if (command == "close") CloseSettings(hwnd); } else if (type == "maximizeButtonRect") { @@ -757,8 +789,11 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args) } else if (type == "skinCatalogRequest") { - PostConfig(true); - NotifyImeServerCandidateSkinRefresh(); + g_worker->Submit([] { + auto completion = ConfigCompletion(true); + NotifyImeServerCandidateSkinRefresh(); + return completion; + }); } else if (type == "openSkinDirectory") { @@ -768,13 +803,17 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args) std::filesystem::create_directories(skins, ec); if (!ec) ShellExecuteW(hwnd, L"open", skins.c_str(), nullptr, nullptr, SW_SHOWNORMAL); } - else if (type == "configUpdate" && ApplyConfigUpdate(value.at("data").as_object())) + else if (type == "configUpdate") { - const auto &data = value.at("data").as_object(); - const std::string path = json::value_to(data.at("path")); - if (path == "appearance.theme_mode" || path == "appearance.theme_settings") - ApplySettingsChromeTheme(hwnd); - PostConfig(); + const auto data = value.at("data").as_object(); + g_worker->Submit([data] { + const bool saved = ApplyConfigUpdate(data); + auto completion = ConfigCompletion(); + return [saved, completion = std::move(completion)] { + completion(); + if (!saved) MessageBoxW(g_settings_hwnd, L"设置保存失败,请重试。", L"水杉输入法", MB_OK | MB_ICONWARNING); + }; + }); } else if (type == "openKeyboardPanel") { @@ -796,16 +835,24 @@ void HandleWebMessage(HWND hwnd, ICoreWebView2WebMessageReceivedEventArgs *args) } else if (type == "dictionaryRequest") { - const auto &data = value.at("data").as_object(); - json::value response = SettingsDictionary::HandleRequest(data); - response.as_object()["type"] = "dictionaryResponse"; - if (const auto *request_id = data.if_contains("requestId"); request_id && request_id->is_string()) - response.as_object()["requestId"] = json::value_to(*request_id); - response.as_object()["protocolVersion"] = metasequoia::webview::Version; - if (!metasequoia::webview::Validate(response, "server")) - return; - const std::wstring message = string_to_wstring(json::serialize(response)); - g_webview->PostWebMessageAsJson(message.c_str()); + const auto data = value.at("data").as_object(); + g_worker->Submit([data]() -> SerialTaskQueue::Completion { + json::value response; + try { response = SettingsDictionary::HandleRequest(data); } + catch (...) { response = json::object{{"ok", false}, {"message", "词库操作失败,请重试"}, {"rows", json::array{}}}; } + auto &object = response.as_object(); + object["type"] = "dictionaryResponse"; + object["requestId"] = data.at("requestId"); + object["action"] = data.at("action"); + object["dictionary"] = data.at("dictionary"); + object["protocolVersion"] = metasequoia::webview::Version; + if (!metasequoia::webview::Validate(response, "server")) + throw std::runtime_error("Invalid dictionary response"); + auto message = string_to_wstring(json::serialize(response)); + return [message = std::move(message)] { + if (g_webview) g_webview->PostWebMessageAsJson(message.c_str()); + }; + }); } } catch (const std::exception &) @@ -883,6 +930,7 @@ HRESULT OnControllerCreated(HWND hwnd, HRESULT result, ICoreWebView2CompositionC g_webview->add_ContentLoading( Callback( [](ICoreWebView2 *, ICoreWebView2ContentLoadingEventArgs *) -> HRESULT { + g_last_config_message.clear(); g_webview_content_started = true; SettingsSplash::Dismiss(); return S_OK; @@ -1027,12 +1075,24 @@ int TopNonClientInset(HWND hwnd) void ActivateWindow(HWND hwnd) { + if (g_closing) return; if (IsIconic(hwnd)) ShowWindow(hwnd, SW_RESTORE); else ShowWindow(hwnd, SW_SHOW); SetForegroundWindow(hwnd); SetFocus(hwnd); } +void CloseSettings(HWND hwnd) +{ + if (g_closing) return; + g_closing = true; + KillTimer(hwnd, kConfigReloadTimer); + SettingsSplash::Dismiss(); + ShowWindow(hwnd, SW_HIDE); + if (g_worker) g_worker->Stop(); + else DestroyWindow(hwnd); +} + LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_param) { switch (message) @@ -1138,7 +1198,7 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa { RECT client{}; GetClientRect(hwnd, &client); - const bool light = ResolveConfiguredTheme(GetConfiguredThemeSettings()) == "light"; + const bool light = g_settings_light; HBRUSH brush = CreateSolidBrush(light ? RGB(243, 243, 243) : RGB(32, 32, 32)); FillRect(reinterpret_cast(w_param), &client, brush); DeleteObject(brush); @@ -1168,16 +1228,33 @@ LRESULT CALLBACK WindowProc(HWND hwnd, UINT message, WPARAM w_param, LPARAM l_pa break; } case WM_TIMER: - if (w_param == kConfigReloadTimer && ReloadImeConfigIfChanged()) + if (w_param == kConfigReloadTimer && g_worker && !g_closing && !g_reload_pending) { - ApplySettingsChromeTheme(hwnd); - PostConfig(); + g_reload_pending = true; + g_worker->Submit([] { + SerialTaskQueue::Completion completion; + try { if (ReloadImeConfigIfChanged()) completion = ConfigCompletion(); } + catch (...) { /* Retry on the next timer tick; do not poison the queue. */ } + return [completion = std::move(completion)] { + g_reload_pending = false; + if (completion) completion(); + }; + }); + } + return 0; + case kWorkerCompleted: + if (g_worker) + { + g_worker->Drain(!g_closing); + if (g_closing && g_worker->Stopped()) DestroyWindow(hwnd); } return 0; case WM_CLOSE: - DestroyWindow(hwnd); + CloseSettings(hwnd); return 0; case WM_DESTROY: + g_closing = true; + if (g_worker) g_worker->Stop(); KillTimer(hwnd, kConfigReloadTimer); SettingsSplash::Dismiss(); g_webview.Reset(); @@ -1214,7 +1291,6 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR command_line, int show_ return 0; } - InitImeConfig(); WNDCLASSEXW window_class{sizeof(window_class)}; window_class.style = CS_HREDRAW | CS_VREDRAW | CS_DBLCLKS; window_class.lpfnWndProc = WindowProc; @@ -1235,6 +1311,14 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR command_line, int show_ x, y, width, height, nullptr, nullptr, instance, nullptr); if (!hwnd) return 1; g_settings_hwnd = hwnd; + g_worker = std::make_unique([hwnd] { PostMessageW(hwnd, kWorkerCompleted, 0, 0); }, + [] { MessageBoxW(g_settings_hwnd, L"设置操作失败,请重试。", L"水杉输入法", MB_OK | MB_ICONWARNING); }, + [] { if (g_worker_com_initialized) CoUninitialize(); }); + g_worker->Submit([] { + g_worker_com_initialized = SUCCEEDED(CoInitializeEx(nullptr, COINIT_MULTITHREADED)); + InitImeConfig(); + return ConfigCompletion(); + }); DWM_WINDOW_CORNER_PREFERENCE corner = DWMWCP_ROUND; DWM_SYSTEMBACKDROP_TYPE backdrop = DWMSBT_NONE; @@ -1254,7 +1338,7 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR command_line, int show_ InitWebView(hwnd); ShowWindow(hwnd, show_command == SW_HIDE ? SW_SHOWNORMAL : show_command); UpdateWindow(hwnd); - SettingsSplash::Show(hwnd); + SettingsSplash::Show(hwnd, g_settings_light); if (g_webview_content_started) SettingsSplash::Dismiss(); @@ -1264,6 +1348,7 @@ int WINAPI wWinMain(HINSTANCE instance, HINSTANCE, PWSTR command_line, int show_ TranslateMessage(&message); DispatchMessageW(&message); } + g_worker.reset(); // Drain accepted writes even if the window was destroyed externally. CoUninitialize(); return static_cast(message.wParam); } diff --git a/server/src/settings/settings_splash.cpp b/server/src/settings/settings_splash.cpp index 4a3a3984..d6f22b8c 100644 --- a/server/src/settings/settings_splash.cpp +++ b/server/src/settings/settings_splash.cpp @@ -1,5 +1,4 @@ #include "settings/settings_splash.h" -#include "config/ime_config.h" #include #include @@ -46,11 +45,6 @@ bool g_close_hover = false; float g_phase = 0.0f; ULONGLONG g_last_tick = 0; -bool IsLightTheme() -{ - return ResolveConfiguredTheme(GetConfiguredThemeSettings()) == "light"; -} - void ReleaseDeviceResources() { g_text_brush.Reset(); @@ -76,7 +70,7 @@ void ApplyNativeChrome(HWND hwnd) DWM_WINDOW_CORNER_PREFERENCE corner = DWMWCP_ROUND; DwmSetWindowAttribute(hwnd, DWMWA_WINDOW_CORNER_PREFERENCE, &corner, sizeof(corner)); - const BOOL dark = IsLightTheme() ? FALSE : TRUE; + const BOOL dark = g_light ? FALSE : TRUE; DwmSetWindowAttribute(hwnd, DWMWA_USE_IMMERSIVE_DARK_MODE, &dark, sizeof(dark)); DWM_SYSTEMBACKDROP_TYPE backdrop = DWMSBT_NONE; @@ -145,7 +139,6 @@ HRESULT EnsureRenderTarget() D2D1::HwndRenderTargetProperties(g_hwnd, D2D1::SizeU(width, height)), &g_rt); if (FAILED(hr)) return hr; - g_light = IsLightTheme(); const D2D1_COLOR_F text = g_light ? D2D1::ColorF(0x1A1A1A) : D2D1::ColorF(0xF0F0F0); const D2D1_COLOR_F muted = g_light ? D2D1::ColorF(0x6B6B6B) : D2D1::ColorF(0xA8A8A8); const D2D1_COLOR_F accent = g_light ? D2D1::ColorF(0x7A3E91) : D2D1::ColorF(0xD88BDE); @@ -342,8 +335,17 @@ ATOM RegisterSplashClass(HINSTANCE instance) namespace SettingsSplash { -bool Show(HWND owner) +void SetTheme(bool light) { + if (g_light == light) return; + g_light = light; + ReleaseDeviceResources(); + if (g_hwnd) { ApplyNativeChrome(g_hwnd); InvalidateRect(g_hwnd, nullptr, FALSE); } +} + +bool Show(HWND owner, bool light) +{ + g_light = light; if (!owner) return false; if (g_hwnd && IsWindow(g_hwnd)) { diff --git a/server/src/settings/settings_splash.h b/server/src/settings/settings_splash.h index 0b059eab..0e5a0001 100644 --- a/server/src/settings/settings_splash.h +++ b/server/src/settings/settings_splash.h @@ -6,7 +6,8 @@ // Uses the same DWM rounded-corner chrome so edges match the host window. namespace SettingsSplash { -bool Show(HWND owner); +bool Show(HWND owner, bool light); +void SetTheme(bool light); void Dismiss(); void SyncToOwner(); bool IsVisible(); diff --git a/server/tests/CMakeLists.txt b/server/tests/CMakeLists.txt index e846dfef..58d10b54 100644 --- a/server/tests/CMakeLists.txt +++ b/server/tests/CMakeLists.txt @@ -30,6 +30,7 @@ set(TEST_SOURCE_FILES "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_text_policy.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_window_template.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_inline_protocol.cpp" + "${CMAKE_CURRENT_SOURCE_DIR}/src/test_settings_performance.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_candidate_size_estimator.cpp" "${CMAKE_SOURCE_DIR}/src/utils/candidate_size_estimator.cpp" "${CMAKE_CURRENT_SOURCE_DIR}/src/test_ipc_protocol_constants.cpp" diff --git a/server/tests/src/test_settings_performance.cpp b/server/tests/src/test_settings_performance.cpp new file mode 100644 index 00000000..f4059e21 --- /dev/null +++ b/server/tests/src/test_settings_performance.cpp @@ -0,0 +1,83 @@ +#include "tests/includes/test_framework.h" +#include "settings/dictionary_page.h" +#include "settings/serial_task_queue.h" +#include +#include +#include + +TEST_CASE(settings_worker_preserves_order_and_drains_on_close) +{ + const auto owner = std::this_thread::get_id(); + std::promise started, release; + auto wait = release.get_future().share(); + std::vector work, delivered; + bool off_ui = false; + { + SerialTaskQueue queue([] {}, [] {}); + REQUIRE(queue.Submit([&] { + off_ui = std::this_thread::get_id() != owner; + started.set_value(); wait.wait(); work.push_back(1); + return [&] { delivered.push_back(1); }; + })); + const auto status = started.get_future().wait_for(std::chrono::seconds(5)); + // Release even if scheduling failed so a failed test cannot deadlock destruction. + if (status != std::future_status::ready) release.set_value(); + REQUIRE(status == std::future_status::ready); + REQUIRE(queue.Submit([&] { work.push_back(2); return [&] { delivered.push_back(2); }; })); + queue.Stop(); + REQUIRE(!queue.Submit([] { return SerialTaskQueue::Completion{}; })); + queue.Drain(false); + REQUIRE(delivered.empty()); + release.set_value(); + } + REQUIRE(off_ui); + REQUIRE_EQ(work, (std::vector{1, 2})); + REQUIRE(delivered.empty()); // Destruction never invokes a callback into a closed UI. +} + +TEST_CASE(settings_worker_delivers_results_only_on_owner_and_survives_errors) +{ + const auto owner = std::this_thread::get_id(); + std::promise done; + int errors = 0, delivered = 0; + SerialTaskQueue queue([] {}, [&] { REQUIRE(std::this_thread::get_id() == owner); ++errors; }, [&] { done.set_value(); }); + queue.Submit([]() -> SerialTaskQueue::Completion { throw std::runtime_error("test"); }); + queue.Submit([&] { return [&] { REQUIRE(std::this_thread::get_id() == owner); ++delivered; }; }); + queue.Stop(); + REQUIRE(done.get_future().wait_for(std::chrono::seconds(5)) == std::future_status::ready); + REQUIRE_EQ(errors, 0); REQUIRE_EQ(delivered, 0); + queue.Drain(); + REQUIRE_EQ(errors, 1); REQUIRE_EQ(delivered, 1); +} + +TEST_CASE(dictionary_pages_cover_ties_without_duplicates_or_unbounded_results) +{ + namespace json = boost::json; + sqlite3 *db = nullptr; + REQUIRE(sqlite3_open(":memory:", &db) == SQLITE_OK); + struct Close { sqlite3 *db; ~Close() { sqlite3_close(db); } } close{db}; + REQUIRE(sqlite3_exec(db, "CREATE TABLE words(code TEXT,word TEXT PRIMARY KEY,weight INTEGER)", nullptr, nullptr, nullptr) == SQLITE_OK); + for (int i = 0; i < 205; ++i) + { + const auto sql = "INSERT INTO words VALUES('a','word" + std::to_string(i) + "',100)"; + REQUIRE(sqlite3_exec(db, sql.c_str(), nullptr, nullptr, nullptr) == SQLITE_OK); + } + std::set seen; + for (int offset : {0, 100, 200}) + { + json::object request{{"offset", offset}, {"limit", 100}}; + const auto sql = "SELECT code,word,weight FROM words ORDER BY weight DESC,code,word" + SettingsDictionary::Paging::Sql(request); + sqlite3_stmt *stmt = nullptr; + REQUIRE(sqlite3_prepare_v2(db, sql.c_str(), -1, &stmt, nullptr) == SQLITE_OK); + auto result = SettingsDictionary::Paging::Read(stmt, request); + sqlite3_finalize(stmt); + REQUIRE(result.at("ok").as_bool()); + REQUIRE_EQ(result.at("offset").as_int64(), offset); + REQUIRE_EQ(result.at("hasMore").as_bool(), offset < 200); + REQUIRE_EQ(result.at("rows").as_array().size(), offset < 200 ? size_t{100} : size_t{5}); + for (const auto &row : result.at("rows").as_array()) + REQUIRE(seen.insert(json::value_to(row.at("word"))).second); + } + REQUIRE_EQ(seen.size(), size_t{205}); + REQUIRE_EQ(SettingsDictionary::Paging::Limit({{"limit", 999999}}), 200); +} diff --git a/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts b/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts index 9c2a9d8e..c5a8a197 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/config-sync.ts @@ -1,22 +1,32 @@ import { onHostMessage } from '../utils/host-messages'; import { serializeHostMessage } from '../../../../shared/messages'; -import { applyCandidateArrange, applyDropdownValue, applyToggleState } from './shared'; +import { applyCandidateArrange, applyDropdownValue as applyDropdown, applyToggleState as applyToggle } from './shared'; let lastSnapshot: Record | null = null; const readyModules = new Set(); export function notifySettingsModuleReady(name: string): void { readyModules.add(name); - if (lastSnapshot) applyConfigData(lastSnapshot); + if (lastSnapshot) applyConfigData(lastSnapshot, name); } -function applyConfigData(data: Record): void { +function applyConfigData(data: Record, target?: string): void { + const applies = (name: string) => readyModules.has(name) && (!target || target === name); + const findElement = (id: string) => { + const element = document.getElementById(id); + return !target || (element && document.getElementById(target)?.contains(element)) ? element : null; + }; + const applyToggleState = (id: string, value: boolean) => { if (findElement(id)) applyToggle(id, value); }; + const applyDropdownValue = (id: string, menu: string, value: string | undefined) => { + if (findElement(id)) applyDropdown(id, menu, value); + }; lastSnapshot = data; - applyCandidateArrange(data?.appearance?.candidate_window_layout); + if (!target || target === 'appearance' || target === 'skin') applyCandidateArrange(data?.appearance?.candidate_window_layout); - if (readyModules.has('appearance')) { + if (applies('appearance')) { void import('./appearance').then((module) => { + if (data !== lastSnapshot) return; module.applyAppearanceConfig( data?.appearance?.candidate_window_preedit_style, data?.appearance?.tsf_preedit_style, @@ -57,8 +67,9 @@ function applyConfigData(data: Record): void { }); } - if (readyModules.has('skin')) { + if (!target || target === 'skin') { void import('./skin').then((module) => { + if (data !== lastSnapshot) return; module.applyCandidateSkinCatalog( data?.appearance?.external_candidate_skins, data?.appearance?.candidate_skin_scan_issues, @@ -102,7 +113,7 @@ function applyConfigData(data: Record): void { } if (typeof data?.general?.candidate_translations === 'boolean') { applyToggleState('candidateTranslationsToggleBtn', data.general.candidate_translations); - document.getElementById('candidateTranslationApiOptions')?.classList.toggle( + findElement('candidateTranslationApiOptions')?.classList.toggle( 'is-disabled', !data.general.candidate_translations ); @@ -144,27 +155,27 @@ function applyConfigData(data: Record): void { applyToggleState('clipboardHistoryToggleBtn', data.utility.clipboard_history); } if (typeof data?.general?.paging_minus_equal === 'boolean') { - const checkbox = document.getElementById('pagingMinusEqualCheckbox') as HTMLInputElement | null; + const checkbox = findElement('pagingMinusEqualCheckbox') as HTMLInputElement | null; if (checkbox) checkbox.checked = data.general.paging_minus_equal; } if (typeof data?.general?.paging_tab === 'boolean') { - const checkbox = document.getElementById('pagingTabCheckbox') as HTMLInputElement | null; + const checkbox = findElement('pagingTabCheckbox') as HTMLInputElement | null; if (checkbox) checkbox.checked = data.general.paging_tab; } if (typeof data?.general?.paging_comma_period === 'boolean') { - const checkbox = document.getElementById('pagingCommaPeriodCheckbox') as HTMLInputElement | null; + const checkbox = findElement('pagingCommaPeriodCheckbox') as HTMLInputElement | null; if (checkbox) checkbox.checked = data.general.paging_comma_period; } if (typeof data?.general?.paging_brackets === 'boolean') { - const checkbox = document.getElementById('pagingBracketsCheckbox') as HTMLInputElement | null; + const checkbox = findElement('pagingBracketsCheckbox') as HTMLInputElement | null; if (checkbox) checkbox.checked = data.general.paging_brackets; } if (typeof data?.general?.paging_page_up_down === 'boolean') { - const checkbox = document.getElementById('pagingPageUpDownCheckbox') as HTMLInputElement | null; + const checkbox = findElement('pagingPageUpDownCheckbox') as HTMLInputElement | null; if (checkbox) checkbox.checked = data.general.paging_page_up_down; } if (typeof data?.general?.candidate_arrow_navigation === 'boolean') { - const checkbox = document.getElementById('candidateArrowNavigationCheckbox') as HTMLInputElement | null; + const checkbox = findElement('candidateArrowNavigationCheckbox') as HTMLInputElement | null; if (checkbox) checkbox.checked = data.general.candidate_arrow_navigation; } if (typeof data?.helpcode?.show_sp_helpcode_in_candidate_window === 'boolean') { @@ -215,8 +226,9 @@ function applyConfigData(data: Record): void { } } - if (readyModules.has('input') || readyModules.has('helpcode') || readyModules.has('tools-settings')) { + if (applies('input') || applies('helpcode') || applies('tools-settings')) { void import('./input').then((module) => { + if (data !== lastSnapshot) return; module.applyInputConfig( data?.input?.mode, data?.input?.schema, @@ -236,14 +248,15 @@ function applyConfigData(data: Record): void { module.applyCustomTranslationConfig(data?.custom_translation); }); } - if (readyModules.has('voice') && data?.voice_input && typeof data.voice_input === 'object') { - void import('./voice').then((module) => module.applyVoiceConfig(data.voice_input)); + if (applies('voice') && data?.voice_input && typeof data.voice_input === 'object') { + void import('./voice').then((module) => { if (data === lastSnapshot) module.applyVoiceConfig(data.voice_input); }); } - if (readyModules.has('ai-settings') && data?.ai_assistant && typeof data.ai_assistant === 'object') { - void import('./ai-settings').then((module) => module.applyAiConfig(data.ai_assistant)); + if (applies('ai-settings') && data?.ai_assistant && typeof data.ai_assistant === 'object') { + void import('./ai-settings').then((module) => { if (data === lastSnapshot) module.applyAiConfig(data.ai_assistant); }); } - if (readyModules.has('floating-toolbar')) { + if (applies('floating-toolbar')) { void import('./floating-toolbar').then((module) => { + if (data !== lastSnapshot) return; module.applyFloatingToolbarItemsConfig({ fullwidth: data?.general?.floating_toolbar_fullwidth, punctuation: data?.general?.floating_toolbar_punctuation, @@ -258,8 +271,8 @@ function applyConfigData(data: Record): void { ); }); } - if (readyModules.has('shortcut')) { - void import('./shortcut').then((module) => module.applyShortcutConfig(data?.keybindings)); + if (applies('shortcut')) { + void import('./shortcut').then((module) => { if (data === lastSnapshot) module.applyShortcutConfig(data?.keybindings); }); } } diff --git a/ui-html/webview2/settings/ime-settings/src/modules/dict.ts b/ui-html/webview2/settings/ime-settings/src/modules/dict.ts index d28486ab..43fe54f9 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/dict.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/dict.ts @@ -1,3 +1,4 @@ +import { createDictionaryPager, DICTIONARY_PAGE_SIZE } from '../utils/dictionary-pagination'; import { onHostMessage } from '../utils/host-messages'; import type { SettingsMessage } from '../../../../shared/messages'; type DictionaryRequest = Extract['data']; @@ -9,14 +10,19 @@ let dictionary: DictionaryType = 'quanpin'; let editing: DictionaryRow | null = null; let requestCounter = 0; let lastQuery = ''; -let lastAction = 'query'; +const pendingRequests = new Map(); +let latestQueryId = ''; +let pager: ReturnType | undefined; let toastTimer: number | null = null; function post(action: DictionaryRequest['action'], data: Partial> = {}): void { - lastAction = action; + const requestId = `dict-${++requestCounter}`; + const targetDictionary = dictionary; + pendingRequests.set(requestId, { action, dictionary: targetDictionary }); + if (action === 'query') { latestQueryId = requestId; pager?.loading(); } window.chrome?.webview?.postMessage(serializeHostMessage({ type: 'dictionaryRequest', - data: { requestId: `dict-${++requestCounter}`, dictionary, action, ...data } + data: { requestId, dictionary, action, ...data } })); } @@ -52,7 +58,7 @@ function renderRows(rows: DictionaryRow[]): void { const tr = document.createElement('tr'); const indexCell = document.createElement('td'); indexCell.className = 'dict-index-column'; - indexCell.textContent = String(index + 1); + indexCell.textContent = String((pager?.offset ?? 0) + index + 1); tr.appendChild(indexCell); const values = dictionary === 'english' ? [row.word, row.display ?? row.word, String(row.weight ?? 0)] @@ -90,6 +96,8 @@ function syncTableHeaderWidth(): void { } function updateMode(): void { + latestQueryId = ''; + pager?.reset(); const english = dictionary === 'english'; const quanpin = dictionary === 'quanpin'; const search = document.getElementById('dictSearch') as HTMLInputElement; @@ -129,10 +137,10 @@ function closeDialog(): void { const modal = document.getElementById('dictModal')!; modal.classList.remove('open'); modal.setAttribute('aria-hidden', 'true'); editing = null; } -function query(): void { - lastQuery = (document.getElementById('dictSearch') as HTMLInputElement).value.trim(); +function query(offset = 0): void { + if (offset === 0) lastQuery = (document.getElementById('dictSearch') as HTMLInputElement).value.trim(); if (!lastQuery) { showToast('请输入查询内容', false); return; } - post('query', dictionary === 'english' ? { word: lastQuery } : { code: lastQuery }); + post('query', { ...(dictionary === 'english' ? { word: lastQuery } : { code: lastQuery }), offset, limit: DICTIONARY_PAGE_SIZE }); } function downloadExport(content: string, filename: string): void { @@ -151,11 +159,13 @@ function downloadExport(content: string, filename: string): void { } export function setupDictionary(): void { + const table = document.querySelector('.dict-table-wrap'); + if (table) pager = createDictionaryPager(table, offset => query(offset)); document.querySelectorAll('.dict-tab').forEach((tab) => tab.addEventListener('click', () => { document.querySelector('.dict-tab.active')?.classList.remove('active'); tab.classList.add('active'); dictionary = tab.dataset.dictionary as DictionaryType; updateMode(); })); - document.getElementById('dictSearchButton')?.addEventListener('click', query); + document.getElementById('dictSearchButton')?.addEventListener('click', () => query()); document.getElementById('dictSearch')?.addEventListener('keydown', (event) => { if ((event as KeyboardEvent).key === 'Enter') query(); }); document.getElementById('dictAddButton')?.addEventListener('click', () => openDialog()); document.getElementById('dictImportButton')?.addEventListener('click', () => { @@ -224,6 +234,17 @@ export function setupDictionary(): void { }); onHostMessage('dictionaryResponse', payload => { if (!payload.requestId.startsWith('dict-')) return; + const context = pendingRequests.get(payload.requestId); + if (!context) return; + pendingRequests.delete(payload.requestId); + if (context.action === 'query' && payload.requestId !== latestQueryId) return; + if (context.action === 'query' && context.dictionary !== dictionary) return; + const lastAction = context.action; + if (lastAction === 'query') { + if (payload.ok) pager?.update(payload.offset ?? 0, payload.rows.length, payload.hasMore === true); + else pager?.failed(); + } + const isImport = lastAction === 'import' || lastAction === 'importHans'; const isExport = lastAction === 'export'; if (isExport && payload.ok && typeof payload.content === 'string' && typeof payload.filename === 'string') { @@ -234,8 +255,8 @@ export function setupDictionary(): void { Boolean(payload.ok), isImport ? 5600 : 3200, ); - if (Array.isArray(payload.rows)) renderRows(payload.rows); - if (payload.ok && lastAction !== 'query' && !isExport) { closeDialog(); if (lastQuery) query(); } + if (lastAction === 'query' && payload.ok) renderRows(payload.rows); + if (payload.ok && lastAction !== 'query' && !isExport && context.dictionary === dictionary) { closeDialog(); if (lastQuery) query(pager?.offset ?? 0); } }); updateMode(); } diff --git a/ui-html/webview2/settings/ime-settings/src/modules/sidebar.ts b/ui-html/webview2/settings/ime-settings/src/modules/sidebar.ts index 07cfe099..9495fde2 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/sidebar.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/sidebar.ts @@ -6,7 +6,6 @@ const BACKGROUND_MODULES = [ 'helpcode', 'shortcut', 'dict', - 'skin', 'voice', 'screenkb-settings', 'handwriting-settings', diff --git a/ui-html/webview2/settings/ime-settings/src/modules/skin.ts b/ui-html/webview2/settings/ime-settings/src/modules/skin.ts index bf2e9793..8086177a 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/skin.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/skin.ts @@ -1,3 +1,7 @@ +let skinInitialized = false; +let catalogKey = ''; +let styleGeneration = 0; +const styleLoads = new Map>(); import { serializeHostMessage } from '../../../../shared/messages'; import { loadHTML } from '../utils/common-utils'; import ftbHTML from '../../../../ftb/default.html?raw'; @@ -138,26 +142,30 @@ function candidatePreviewCss(skin: ExternalSkin): string { return css; } -async function injectScopedSkinCss(skin: ExternalSkin, stylesheet: string | undefined, styleId: string, force = false): Promise { - if (!stylesheet) return; - if (!force && loadedExternalStyleIds.has(styleId) && document.getElementById(styleId)) return; - try { - const response = await fetch(resourceUrl(skin.id, stylesheet), { cache: 'no-store' }); - if (!response.ok) return; - const css = rewriteSkinCssUrls(await response.text(), skin.id).replace(/:root\b/g, ':scope'); - const scoped = `@scope ([data-external-skin-preview="${skin.id}"]) {\n${css}\n}`; - let style = document.getElementById(styleId) as HTMLStyleElement | null; - if (!style) { - style = document.createElement('style'); - style.id = styleId; - style.dataset.externalSkinStyle = skin.id; - document.head.appendChild(style); +function injectScopedSkinCss(skin: ExternalSkin, stylesheet: string | undefined, styleId: string, force = false): Promise { + if (!stylesheet || (!force && loadedExternalStyleIds.has(styleId) && document.getElementById(styleId))) return Promise.resolve(); + const pending = styleLoads.get(styleId); + if (pending) return pending; + const generation = styleGeneration; + const task = (async () => { + try { + const response = await fetch(resourceUrl(skin.id, stylesheet), { cache: 'no-store' }); + if (!response.ok) return; + const css = rewriteSkinCssUrls(await response.text(), skin.id).replace(/:root\b/g, ':scope'); + if (generation !== styleGeneration) return; + let style = document.getElementById(styleId) as HTMLStyleElement | null; + if (!style) { + style = document.createElement('style'); style.id = styleId; + style.dataset.externalSkinStyle = skin.id; document.head.appendChild(style); + } + style.textContent = `@scope ([data-external-skin-preview="${skin.id}"]) {\n${css}\n}`; + loadedExternalStyleIds.add(styleId); + } catch { + // Keep the inherited built-in preview if a stylesheet is unavailable. } - style.textContent = scoped; - loadedExternalStyleIds.add(styleId); - } catch { - // Preview stays on the inherited built-in base if the stylesheet cannot load. - } + })().finally(() => { if (styleLoads.get(styleId) === task) styleLoads.delete(styleId); }); + styleLoads.set(styleId, task); + return task; } function injectGeneratedCandidateCss(skin: ExternalSkin, force = false): void { @@ -183,6 +191,8 @@ async function ensureExternalSkinStyle(skin: ExternalSkin, force = false): Promi } function resetExternalSkinStyles(): void { + styleGeneration++; + styleLoads.clear(); document.querySelectorAll('style[data-external-skin-style]').forEach((node) => node.remove()); loadedExternalStyleIds.clear(); } @@ -302,6 +312,7 @@ function bindSkinSwitch(element: HTMLElement, skinId: string, enabled: boolean): } function renderExternalSkins(): void { + if (!skinInitialized) return; const list = document.getElementById('externalSkinList'); const empty = document.getElementById('externalSkinEmpty'); const directory = document.getElementById('externalSkinDirectory'); @@ -389,7 +400,7 @@ function renderExternalSkins(): void { previews.append(horizontal, vertical, toolbar); card.append(header, previews); - void ensureExternalSkinStyle(skin, true).then(() => applyExternalCardTheme(skin)); + void ensureExternalSkinStyle(skin).then(() => applyExternalCardTheme(skin)); return card; })); empty.textContent = catalogScanned ? '没有发现外部皮肤。' : '尚未扫描。点击“刷新皮肤”读取皮肤目录。'; @@ -416,6 +427,9 @@ function renderExternalSkins(): void { export function applyCandidateSkinCatalog( skins: unknown, issues: unknown, directory: unknown, scanned: unknown, revision: unknown ): void { + const key = JSON.stringify([skins, issues, directory, scanned, revision]); + if (key === catalogKey) return; + catalogKey = key; const nextRevision = typeof revision === 'number' && Number.isFinite(revision) ? revision : 0; if (nextRevision !== catalogRevision) resetExternalSkinStyles(); externalSkins = Array.isArray(skins) ? skins.filter((skin): skin is ExternalSkin => @@ -449,7 +463,6 @@ export function syncSkinPreviewTheme(theme: SkinPreviewTheme): void { } export async function setupSkin(): Promise { - resetExternalSkinStyles(); previewHorizontalHtml = await loadHTML('/src/partials/candidate/candidate-wnd-h.html'); previewVerticalHtml = await loadHTML('/src/partials/candidate/candidate-wnd-v.html'); document.querySelectorAll('[data-skin-horizontal]').forEach((host) => { @@ -459,6 +472,7 @@ export async function setupSkin(): Promise { host.innerHTML = previewVerticalHtml; limitCandidatePreview(host); }); document.querySelectorAll('[data-skin-toolbar]').forEach(fillToolbar); + skinInitialized = true; BUILTIN_SKINS.forEach((skin) => applyBuiltinCardTheme(skin, resolvedPreviewTheme(skin))); syncSkinSwitches(); syncAppearancePreviews(); diff --git a/ui-html/webview2/settings/ime-settings/src/modules/tools-settings.ts b/ui-html/webview2/settings/ime-settings/src/modules/tools-settings.ts index 38ba3b51..a6acb202 100644 --- a/ui-html/webview2/settings/ime-settings/src/modules/tools-settings.ts +++ b/ui-html/webview2/settings/ime-settings/src/modules/tools-settings.ts @@ -1,3 +1,4 @@ +import { createDictionaryPager, DICTIONARY_PAGE_SIZE } from '../utils/dictionary-pagination'; import { onHostMessage } from '../utils/host-messages'; import type { SettingsMessage } from '../../../../shared/messages'; type DictionaryRequest = Extract['data']; @@ -9,14 +10,20 @@ type QuickPhraseRow = { code: string; word: string; weight: number }; let editing: QuickPhraseRow | null = null; let requestCounter = 0; -let lastAction = 'query'; +const pendingRequests = new Map(); +let latestQueryId = ''; +let lastQuery = ''; +let pager: ReturnType | undefined; let toastTimer: number | null = null; function post(action: DictionaryRequest['action'], data: Partial> = {}): void { - lastAction = action; + const requestId = `quick-${++requestCounter}`; + const targetDictionary = 'quick'; + pendingRequests.set(requestId, { action, dictionary: targetDictionary }); + if (action === 'query') { latestQueryId = requestId; pager?.loading(); } window.chrome?.webview?.postMessage(serializeHostMessage({ type: 'dictionaryRequest', - data: { requestId: `quick-${++requestCounter}`, dictionary: 'quick', action, ...data } + data: { requestId, dictionary: 'quick', action, ...data } })); } @@ -46,7 +53,7 @@ function renderRows(rows: QuickPhraseRow[]): void { if (!rows.length) { body.innerHTML = '没有找到快捷短语'; syncHeader(); return; } body.replaceChildren(...rows.map((row, index) => { const tr = document.createElement('tr'); - [String(index + 1), row.code, row.word, String(row.weight)].forEach((value, cellIndex) => { + [String((pager?.offset ?? 0) + index + 1), row.code, row.word, String(row.weight)].forEach((value, cellIndex) => { const td = document.createElement('td'); td.textContent = value; if (cellIndex === 0) td.className = 'dict-index-column'; td.addEventListener('mouseenter', () => td.scrollWidth > td.clientWidth ? td.title = value : td.removeAttribute('title')); @@ -74,8 +81,9 @@ function closeDialog(): void { const modal = document.getElementById('quickPhraseModal')!; modal.classList.remove('open'); modal.setAttribute('aria-hidden', 'true'); editing = null; } -function query(): void { - post('query', { code: (document.getElementById('quickPhraseSearch') as HTMLInputElement).value.trim() }); +function query(offset = 0): void { + if (offset === 0) lastQuery = (document.getElementById('quickPhraseSearch') as HTMLInputElement).value.trim(); + post('query', { code: lastQuery, offset, limit: DICTIONARY_PAGE_SIZE }); } function downloadExport(content: string, filename: string): void { @@ -94,6 +102,8 @@ function downloadExport(content: string, filename: string): void { } export function setupToolsSettings(): void { + const table = document.getElementById('quickPhraseTableWrap'); + if (table) pager = createDictionaryPager(table, offset => query(offset)); setupToggleButton('clipboardHistoryToggleBtn', (active) => { updateConfig('utility.clipboard_history', active); }); @@ -121,7 +131,7 @@ export function setupToolsSettings(): void { setupToggleButton('rModeToggleBtn', (active) => { updateConfig('utility.r_mode', active); }); - document.getElementById('quickPhraseSearchButton')?.addEventListener('click', query); + document.getElementById('quickPhraseSearchButton')?.addEventListener('click', () => query()); document.getElementById('quickPhraseSearch')?.addEventListener('keydown', event => { if ((event as KeyboardEvent).key === 'Enter') query(); }); document.getElementById('quickPhraseAddButton')?.addEventListener('click', () => openDialog()); document.getElementById('quickPhraseImportButton')?.addEventListener('click', () => { @@ -158,14 +168,24 @@ export function setupToolsSettings(): void { window.addEventListener('resize', syncHeader); onHostMessage('dictionaryResponse', payload => { if (!payload.requestId.startsWith('quick-')) return; + const context = pendingRequests.get(payload.requestId); + if (!context) return; + pendingRequests.delete(payload.requestId); + if (context.action === 'query' && payload.requestId !== latestQueryId) return; + const lastAction = context.action; + if (lastAction === 'query') { + if (payload.ok) pager?.update(payload.offset ?? 0, payload.rows.length, payload.hasMore === true); + else pager?.failed(); + } + const isImport = lastAction === 'import'; const isExport = lastAction === 'export'; if (isExport && payload.ok && typeof payload.content === 'string' && typeof payload.filename === 'string') { downloadExport(payload.content, payload.filename); } showToast(payload.message ?? (payload.ok ? '操作成功' : '操作失败'), Boolean(payload.ok), isImport ? 5600 : 3200); - renderRows(payload.rows.filter((row): row is QuickPhraseRow => + if (lastAction === 'query' && payload.ok) renderRows(payload.rows.filter((row): row is QuickPhraseRow => typeof row.code === 'string' && typeof row.weight === 'number')); - if (payload.ok && lastAction !== 'query' && !isExport) { closeDialog(); query(); } + if (payload.ok && lastAction !== 'query' && !isExport) { closeDialog(); query(pager?.offset ?? 0); } }); } diff --git a/ui-html/webview2/settings/ime-settings/src/styles/modules/dict.css b/ui-html/webview2/settings/ime-settings/src/styles/modules/dict.css index ab7a3a87..054f00cb 100644 --- a/ui-html/webview2/settings/ime-settings/src/styles/modules/dict.css +++ b/ui-html/webview2/settings/ime-settings/src/styles/modules/dict.css @@ -400,3 +400,6 @@ html[data-theme="dark"] .dict-toast.error .dict-toast-icon { .dict-toast-close:focus { outline: none; } + +.dict-pagination { display: flex; align-items: center; gap: 12px; margin: 12px 0; } +.dict-pagination button:disabled { opacity: .45; cursor: default; } diff --git a/ui-html/webview2/settings/ime-settings/src/utils/dictionary-pagination.ts b/ui-html/webview2/settings/ime-settings/src/utils/dictionary-pagination.ts new file mode 100644 index 00000000..3bbcb457 --- /dev/null +++ b/ui-html/webview2/settings/ime-settings/src/utils/dictionary-pagination.ts @@ -0,0 +1,35 @@ +export const DICTIONARY_PAGE_SIZE = 100; + +export function createDictionaryPager(table: HTMLElement, load: (offset: number) => void) { + let offset = 0; + let hasMore = false; + let pending = false; + const footer = document.createElement('div'); + footer.className = 'dict-pagination'; + const previous = document.createElement('button'); + const next = document.createElement('button'); + const status = document.createElement('span'); + previous.type = next.type = 'button'; + previous.className = next.className = 'dict-button secondary'; + previous.textContent = '上一页'; next.textContent = '下一页'; + status.setAttribute('aria-live', 'polite'); + footer.append(previous, status, next); table.after(footer); + const sync = () => { + previous.disabled = pending || offset === 0; + next.disabled = pending || !hasMore; + }; + previous.addEventListener('click', () => load(Math.max(0, offset - DICTIONARY_PAGE_SIZE))); + next.addEventListener('click', () => load(offset + DICTIONARY_PAGE_SIZE)); + sync(); + return { + get offset() { return offset; }, + loading() { pending = true; status.textContent = '查询中…'; sync(); }, + failed() { pending = false; status.textContent = '查询失败,请重试'; sync(); }, + reset() { offset = 0; hasMore = false; pending = false; status.textContent = ''; sync(); }, + update(start: number, count: number, more: boolean) { + offset = start; hasMore = more; pending = false; + status.textContent = count ? `第 ${start + 1}–${start + count} 条${more ? ',后面还有结果' : ''}` : '没有更多结果'; + sync(); + }, + }; +} diff --git a/ui-html/webview2/shared/messages.ts b/ui-html/webview2/shared/messages.ts index cb818d58..a0c63f87 100644 --- a/ui-html/webview2/shared/messages.ts +++ b/ui-html/webview2/shared/messages.ts @@ -15,7 +15,7 @@ export type ClientMessage = | { "type": "windowControl"; "protocolVersion"?: 1; "data": "minimize" | "maximize" | "restore" | "close" } | { "type": "maximizeButtonRect"; "protocolVersion"?: 1; "data": { "x": number; "y": number; "width": number; "height": number; "dpr": number } } | { "type": "configUpdate"; "protocolVersion"?: 1; "data": { "path": string; "value": string | number | boolean } } - | { "type": "dictionaryRequest"; "protocolVersion"?: 1; "data": { "requestId": string; "dictionary": "quanpin" | "wubi" | "english" | "quick"; "action": "query" | "create" | "update" | "delete" | "import" | "importHans" | "export"; "word"?: string; "code"?: string; "content"?: string; "weight"?: number; "oldWord"?: string; "oldCode"?: string; "display"?: string; "oldDisplay"?: string } } + | { "type": "dictionaryRequest"; "protocolVersion"?: 1; "data": { "requestId": string; "dictionary": "quanpin" | "wubi" | "english" | "quick"; "action": "query" | "create" | "update" | "delete" | "import" | "importHans" | "export"; "word"?: string; "code"?: string; "content"?: string; "weight"?: number; "oldWord"?: string; "oldCode"?: string; "display"?: string; "oldDisplay"?: string; "offset"?: number; "limit"?: number } } | { "type": "openKeyboardPanel"; "protocolVersion"?: 1 } | { "type": "candidate"; "protocolVersion"?: 1; "data": number } | { "type": "delete"; "protocolVersion"?: 1; "data": number } @@ -48,7 +48,7 @@ export type ServerMessage = | { "type": "configSnapshot"; "protocolVersion"?: 1; "data": { [key: string]: unknown } } | { "type": "windowState"; "protocolVersion"?: 1; "data": { "isMaximized": boolean } } | { "type": "maxButtonEvent"; "protocolVersion"?: 1; "data": { "event": "enter" | "leave" | "down" | "up" | "click" } } - | { "type": "dictionaryResponse"; "protocolVersion"?: 1; "requestId": string; "action"?: string; "dictionary"?: string; "ok": boolean; "message": string; "rows": Array<{ "word": string; "code"?: string; "display"?: string; "weight"?: number }>; "content"?: string; "filename"?: string; [key: string]: unknown }; + | { "type": "dictionaryResponse"; "protocolVersion"?: 1; "requestId": string; "action"?: string; "dictionary"?: string; "ok": boolean; "message": string; "rows": Array<{ "word": string; "code"?: string; "display"?: string; "weight"?: number }>; "content"?: string; "filename"?: string; "offset"?: number; "hasMore"?: boolean; [key: string]: unknown }; export type SettingsMessage = | { "type": "configRequest"; "protocolVersion"?: 1 } | { "type": "skinCatalogRequest"; "protocolVersion"?: 1 } @@ -63,7 +63,7 @@ export type SettingsMessage = | { "type": "windowControl"; "protocolVersion"?: 1; "data": "minimize" | "maximize" | "restore" | "close" } | { "type": "maximizeButtonRect"; "protocolVersion"?: 1; "data": { "x": number; "y": number; "width": number; "height": number; "dpr": number } } | { "type": "configUpdate"; "protocolVersion"?: 1; "data": { "path": string; "value": string | number | boolean } } - | { "type": "dictionaryRequest"; "protocolVersion"?: 1; "data": { "requestId": string; "dictionary": "quanpin" | "wubi" | "english" | "quick"; "action": "query" | "create" | "update" | "delete" | "import" | "importHans" | "export"; "word"?: string; "code"?: string; "content"?: string; "weight"?: number; "oldWord"?: string; "oldCode"?: string; "display"?: string; "oldDisplay"?: string } } + | { "type": "dictionaryRequest"; "protocolVersion"?: 1; "data": { "requestId": string; "dictionary": "quanpin" | "wubi" | "english" | "quick"; "action": "query" | "create" | "update" | "delete" | "import" | "importHans" | "export"; "word"?: string; "code"?: string; "content"?: string; "weight"?: number; "oldWord"?: string; "oldCode"?: string; "display"?: string; "oldDisplay"?: string; "offset"?: number; "limit"?: number } } | { "type": "openKeyboardPanel"; "protocolVersion"?: 1 }; declare global { var MsimeProtocol: { diff --git a/ui-html/webview2/shared/schema.js b/ui-html/webview2/shared/schema.js index b9d71fdf..a25871f6 100644 --- a/ui-html/webview2/shared/schema.js +++ b/ui-html/webview2/shared/schema.js @@ -1,6 +1,6 @@ // Generated by generate.py; edit messages.json. (function(root) { - const schema = {"version":1,"client":{"configRequest":{"surfaces":["settings"]},"skinCatalogRequest":{"surfaces":["settings"]},"openSkinDirectory":{"surfaces":["settings"]},"openHandwritingPanel":{"surfaces":["settings"]},"copyText":{"surfaces":["settings"],"data":{"type":"string"}},"openExternalUrl":{"surfaces":["settings"],"data":{"type":"string"}},"dragStart":{"surfaces":["settings","toolbar"]},"focus":{"surfaces":["settings"]},"resizeStart":{"surfaces":["settings"],"data":{"type":"string","enum":["left","right","top","bottom","left-top","right-top","left-bottom","right-bottom","client"]}},"resizeHitTest":{"surfaces":["settings"],"data":{"type":"string","enum":["left","right","top","bottom","left-top","right-top","left-bottom","right-bottom","client"]}},"windowControl":{"surfaces":["settings"],"data":{"type":"string","enum":["minimize","maximize","restore","close"]}},"maximizeButtonRect":{"surfaces":["settings"],"data":{"type":"object","properties":{"x":{"type":"number"},"y":{"type":"number"},"width":{"type":"number","minimum":0},"height":{"type":"number","minimum":0},"dpr":{"type":"number","minimum":0.01}},"required":["x","y","width","height","dpr"],"additionalProperties":false}},"configUpdate":{"surfaces":["settings"],"data":{"type":"object","properties":{"path":{"type":"string"},"value":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}]}},"required":["path","value"],"additionalProperties":false}},"dictionaryRequest":{"surfaces":["settings"],"data":{"type":"object","properties":{"requestId":{"type":"string"},"dictionary":{"type":"string","enum":["quanpin","wubi","english","quick"]},"action":{"type":"string","enum":["query","create","update","delete","import","importHans","export"]},"word":{"type":"string"},"code":{"type":"string"},"content":{"type":"string"},"weight":{"type":"integer"},"oldWord":{"type":"string"},"oldCode":{"type":"string"},"display":{"type":"string"},"oldDisplay":{"type":"string"}},"required":["requestId","dictionary","action"],"additionalProperties":false}},"openKeyboardPanel":{"surfaces":["settings","toolbar"]},"candidate":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"delete":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"pin":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"clearPosition":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"fixPosition":{"surfaces":["candidate"],"data":{"type":"object","properties":{"index":{"type":"integer","minimum":1,"maximum":10},"position":{"type":"integer","minimum":1,"maximum":5}},"required":["index","position"],"additionalProperties":false}},"contextMenuResize":{"surfaces":["candidate"],"data":{"type":"object","properties":{"width":{"type":"integer","minimum":0,"maximum":65535},"height":{"type":"integer","minimum":0,"maximum":65535},"topExpansion":{"type":"integer","minimum":0,"maximum":65535},"leftExpansion":{"type":"integer","minimum":0,"maximum":65535}},"required":["width","height"],"additionalProperties":false}},"contextMenuClosed":{"surfaces":["candidate"]},"candidatePointerArmed":{"surfaces":["candidate"]},"candidatePointerMotion":{"surfaces":["candidate"]},"candidateFrameProbe":{"surfaces":["candidate"],"data":{"type":"object","properties":{"revision":{"type":"integer"},"stage":{"type":"integer"},"performanceMs":{"type":"number","minimum":0}},"required":["revision","stage","performanceMs"],"additionalProperties":false}},"candidatePointerProbe":{"surfaces":["candidate"],"data":{"type":"object","properties":{"probe":{"type":"integer"},"screenX":{"type":"integer"},"screenY":{"type":"integer"},"clientX":{"type":"integer"},"clientY":{"type":"integer"},"movementX":{"type":"integer"},"movementY":{"type":"integer"},"armed":{"type":"boolean"}},"required":["probe","screenX","screenY","clientX","clientY","movementX","movementY","armed"],"additionalProperties":false}},"contentTruncated":{"surfaces":["candidate","toolbar","menu"],"data":{"type":"object","properties":{"surface":{"type":"string","enum":["candidate","toolbar","menu","ftb","candwnd","cand"]},"width":{"type":"number","minimum":0},"height":{"type":"number","minimum":0}},"required":["surface","width","height"],"additionalProperties":true}},"floatingToggle":{"surfaces":["menu"],"data":{"type":"boolean"}},"settings":{"surfaces":["menu"]},"about":{"surfaces":["menu"]},"emojiSymbols":{"surfaces":["menu"]},"keyboardPanel":{"surfaces":["menu"]},"handwritingPanel":{"surfaces":["menu"]},"voiceInput":{"surfaces":["menu"]},"ready":{"surfaces":["toolbar"]},"exitEnglishInputMode":{"surfaces":["toolbar"]},"changeCharacterSet":{"surfaces":["toolbar"]},"openSettings":{"surfaces":["toolbar"]},"openEmojiPanel":{"surfaces":["toolbar"]},"changeIMEMode":{"surfaces":["toolbar"],"data":{"type":"string","enum":["cn","en"]}},"changeCharMode":{"surfaces":["toolbar"],"data":{"type":"string","enum":["fullwidth","halfwidth"]}},"changePuncMode":{"surfaces":["toolbar"],"data":{"type":"string","enum":["puncEn","puncCn"]}}},"server":{"configSnapshot":{"data":{"type":"object","properties":{},"required":[],"additionalProperties":true}},"windowState":{"data":{"type":"object","properties":{"isMaximized":{"type":"boolean"}},"required":["isMaximized"],"additionalProperties":false}},"maxButtonEvent":{"data":{"type":"object","properties":{"event":{"type":"string","enum":["enter","leave","down","up","click"]}},"required":["event"],"additionalProperties":false}},"dictionaryResponse":{"properties":{"requestId":{"type":"string"},"action":{"type":"string"},"dictionary":{"type":"string"},"ok":{"type":"boolean"},"message":{"type":"string"},"rows":{"type":"array","items":{"type":"object","properties":{"word":{"type":"string"},"code":{"type":"string"},"display":{"type":"string"},"weight":{"type":"integer"}},"required":["word"],"additionalProperties":false}},"content":{"type":"string"},"filename":{"type":"string"}},"required":["requestId","ok","message","rows"],"additionalProperties":true}}}; + const schema = {"version":1,"client":{"configRequest":{"surfaces":["settings"]},"skinCatalogRequest":{"surfaces":["settings"]},"openSkinDirectory":{"surfaces":["settings"]},"openHandwritingPanel":{"surfaces":["settings"]},"copyText":{"surfaces":["settings"],"data":{"type":"string"}},"openExternalUrl":{"surfaces":["settings"],"data":{"type":"string"}},"dragStart":{"surfaces":["settings","toolbar"]},"focus":{"surfaces":["settings"]},"resizeStart":{"surfaces":["settings"],"data":{"type":"string","enum":["left","right","top","bottom","left-top","right-top","left-bottom","right-bottom","client"]}},"resizeHitTest":{"surfaces":["settings"],"data":{"type":"string","enum":["left","right","top","bottom","left-top","right-top","left-bottom","right-bottom","client"]}},"windowControl":{"surfaces":["settings"],"data":{"type":"string","enum":["minimize","maximize","restore","close"]}},"maximizeButtonRect":{"surfaces":["settings"],"data":{"type":"object","properties":{"x":{"type":"number"},"y":{"type":"number"},"width":{"type":"number","minimum":0},"height":{"type":"number","minimum":0},"dpr":{"type":"number","minimum":0.01}},"required":["x","y","width","height","dpr"],"additionalProperties":false}},"configUpdate":{"surfaces":["settings"],"data":{"type":"object","properties":{"path":{"type":"string"},"value":{"anyOf":[{"type":"string"},{"type":"number"},{"type":"boolean"}]}},"required":["path","value"],"additionalProperties":false}},"dictionaryRequest":{"surfaces":["settings"],"data":{"type":"object","properties":{"requestId":{"type":"string"},"dictionary":{"type":"string","enum":["quanpin","wubi","english","quick"]},"action":{"type":"string","enum":["query","create","update","delete","import","importHans","export"]},"word":{"type":"string"},"code":{"type":"string"},"content":{"type":"string"},"weight":{"type":"integer"},"oldWord":{"type":"string"},"oldCode":{"type":"string"},"display":{"type":"string"},"oldDisplay":{"type":"string"},"offset":{"type":"integer","minimum":0,"maximum":2147483647},"limit":{"type":"integer","minimum":1,"maximum":200}},"required":["requestId","dictionary","action"],"additionalProperties":false}},"openKeyboardPanel":{"surfaces":["settings","toolbar"]},"candidate":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"delete":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"pin":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"clearPosition":{"surfaces":["candidate"],"data":{"type":"integer","minimum":1,"maximum":10}},"fixPosition":{"surfaces":["candidate"],"data":{"type":"object","properties":{"index":{"type":"integer","minimum":1,"maximum":10},"position":{"type":"integer","minimum":1,"maximum":5}},"required":["index","position"],"additionalProperties":false}},"contextMenuResize":{"surfaces":["candidate"],"data":{"type":"object","properties":{"width":{"type":"integer","minimum":0,"maximum":65535},"height":{"type":"integer","minimum":0,"maximum":65535},"topExpansion":{"type":"integer","minimum":0,"maximum":65535},"leftExpansion":{"type":"integer","minimum":0,"maximum":65535}},"required":["width","height"],"additionalProperties":false}},"contextMenuClosed":{"surfaces":["candidate"]},"candidatePointerArmed":{"surfaces":["candidate"]},"candidatePointerMotion":{"surfaces":["candidate"]},"candidateFrameProbe":{"surfaces":["candidate"],"data":{"type":"object","properties":{"revision":{"type":"integer"},"stage":{"type":"integer"},"performanceMs":{"type":"number","minimum":0}},"required":["revision","stage","performanceMs"],"additionalProperties":false}},"candidatePointerProbe":{"surfaces":["candidate"],"data":{"type":"object","properties":{"probe":{"type":"integer"},"screenX":{"type":"integer"},"screenY":{"type":"integer"},"clientX":{"type":"integer"},"clientY":{"type":"integer"},"movementX":{"type":"integer"},"movementY":{"type":"integer"},"armed":{"type":"boolean"}},"required":["probe","screenX","screenY","clientX","clientY","movementX","movementY","armed"],"additionalProperties":false}},"contentTruncated":{"surfaces":["candidate","toolbar","menu"],"data":{"type":"object","properties":{"surface":{"type":"string","enum":["candidate","toolbar","menu","ftb","candwnd","cand"]},"width":{"type":"number","minimum":0},"height":{"type":"number","minimum":0}},"required":["surface","width","height"],"additionalProperties":true}},"floatingToggle":{"surfaces":["menu"],"data":{"type":"boolean"}},"settings":{"surfaces":["menu"]},"about":{"surfaces":["menu"]},"emojiSymbols":{"surfaces":["menu"]},"keyboardPanel":{"surfaces":["menu"]},"handwritingPanel":{"surfaces":["menu"]},"voiceInput":{"surfaces":["menu"]},"ready":{"surfaces":["toolbar"]},"exitEnglishInputMode":{"surfaces":["toolbar"]},"changeCharacterSet":{"surfaces":["toolbar"]},"openSettings":{"surfaces":["toolbar"]},"openEmojiPanel":{"surfaces":["toolbar"]},"changeIMEMode":{"surfaces":["toolbar"],"data":{"type":"string","enum":["cn","en"]}},"changeCharMode":{"surfaces":["toolbar"],"data":{"type":"string","enum":["fullwidth","halfwidth"]}},"changePuncMode":{"surfaces":["toolbar"],"data":{"type":"string","enum":["puncEn","puncCn"]}}},"server":{"configSnapshot":{"data":{"type":"object","properties":{},"required":[],"additionalProperties":true}},"windowState":{"data":{"type":"object","properties":{"isMaximized":{"type":"boolean"}},"required":["isMaximized"],"additionalProperties":false}},"maxButtonEvent":{"data":{"type":"object","properties":{"event":{"type":"string","enum":["enter","leave","down","up","click"]}},"required":["event"],"additionalProperties":false}},"dictionaryResponse":{"properties":{"requestId":{"type":"string"},"action":{"type":"string"},"dictionary":{"type":"string"},"ok":{"type":"boolean"},"message":{"type":"string"},"rows":{"type":"array","items":{"type":"object","properties":{"word":{"type":"string"},"code":{"type":"string"},"display":{"type":"string"},"weight":{"type":"integer"}},"required":["word"],"additionalProperties":false}},"content":{"type":"string"},"filename":{"type":"string"},"offset":{"type":"integer","minimum":0},"hasMore":{"type":"boolean"}},"required":["requestId","ok","message","rows"],"additionalProperties":true}}}; root.MsimeMessageSchema = schema; if (typeof module !== 'undefined' && module.exports) module.exports = schema; })(globalThis); diff --git a/vendor/MetasequoiaImeEngine b/vendor/MetasequoiaImeEngine index 6bd22549..403b4a4d 160000 --- a/vendor/MetasequoiaImeEngine +++ b/vendor/MetasequoiaImeEngine @@ -1 +1 @@ -Subproject commit 6bd22549d6e275e401052c55cca6c57fe8408819 +Subproject commit 403b4a4d72b0ab5110155aeb4a1f0c170730fc7b