diff --git a/.gitignore b/.gitignore index f1b3b6243..d24faabe5 100644 --- a/.gitignore +++ b/.gitignore @@ -4,3 +4,7 @@ build64 build_arm64/ node/input_methods/McBopomofo installer/*.exe +PIMELauncher/target/ +.claude/ +cleanup_x64.ps1 +replace_x64_dll.ps1 diff --git a/PIMELauncher/src/backend_manager.rs b/PIMELauncher/src/backend_manager.rs index ae5381546..4319ef71f 100644 --- a/PIMELauncher/src/backend_manager.rs +++ b/PIMELauncher/src/backend_manager.rs @@ -72,17 +72,31 @@ impl BackendManager { /// Retrieves a channel to send messages directly to the backend. pub async fn get_backend_input(&self, backend_name: &str) -> Option> { - let mut state = self.state.lock().await; - if !state.backends.contains_key(backend_name) { - // Dynamically look up the backend configuration - if let Some(config) = self.registry.get_backend(backend_name) { - let backend = self.spawn_backend_process(config).await; - state.backends.insert(backend_name.to_string(), backend); - } else { + // Fast path: backend already running — take and release the lock immediately. + { + let state = self.state.lock().await; + if let Some(b) = state.backends.get(backend_name) { + return Some(b.stdin_tx.clone()); + } + } + + // Slow path: spawn the backend without holding the lock, so other clients + // are not blocked during the (potentially multi-second) process startup. + let config = match self.registry.get_backend(backend_name) { + Some(c) => c.clone(), + None => { error!("Unknown backend requested: {}", backend_name); return None; } - } + }; + let backend = self.spawn_backend_process(&config).await; + + // Re-acquire lock to insert; use entry() so a concurrent spawn doesn't overwrite. + let mut state = self.state.lock().await; + state + .backends + .entry(backend_name.to_string()) + .or_insert(backend); state.backends.get(backend_name).map(|b| b.stdin_tx.clone()) } @@ -213,6 +227,7 @@ impl BackendManager { .current_dir(&working_dir) .creation_flags(CREATE_NO_WINDOW) .env("PYTHONIOENCODING", "utf-8:ignore") + .env("PYTHONUNBUFFERED", "1") .stdin(std::process::Stdio::piped()) .stdout(std::process::Stdio::piped()) .stderr(std::process::Stdio::piped()) @@ -289,14 +304,14 @@ impl BackendManager { loop { tokio::select! { msg = stdin_rx.recv() => { - let Some(data) = msg else { + let Some(data) = msg else { info!("Backend {} stdin channel closed. Exiting input loop.", backend_name); - break; + break; }; let now = Self::current_ms(); last_request_time = Some(now); info!("Backend {} received request from channel. Data len: {}. req_t={}", backend_name, data.len(), now); - + // LinesCodec expects data without the newline, it will add it for us. let write_res = tokio::time::timeout(Duration::from_secs(5), stdin_writer.send(data)).await; if let Err(_) = write_res { @@ -315,12 +330,12 @@ impl BackendManager { if let Some(req_t) = last_request_time { let last_out = last_output_time.load(Ordering::SeqCst); // Log tick status occasionally or at least for debugging - info!("Watchdog tick for {}: last_out={}, req_t={}, now={}, delta={}", + debug!("Watchdog tick for {}: last_out={}, req_t={}, now={}, delta={}", backend_name, last_out, req_t, now, now as i64 - req_t as i64); - + // If no output has been received since the last request and it's been more than 15 seconds if last_out < req_t && (now - req_t) > 15000 { - error!("Backend {} seems to be hung (no output for 15s after request). last_out={}, req_t={}, now={}. Forcing restart.", + error!("Backend {} seems to be hung (no output for 15s after request). last_out={}, req_t={}, now={}. Forcing restart.", backend_name, last_out, req_t, now); let _ = child_process.kill().await; break; diff --git a/PIMELauncher/src/main.rs b/PIMELauncher/src/main.rs index 12186bf11..1ce5ca8ae 100644 --- a/PIMELauncher/src/main.rs +++ b/PIMELauncher/src/main.rs @@ -117,6 +117,7 @@ async fn main() { // Writing to a non-existent stdout in a GUI subsystem app can cause hangs. tracing_subscriber::fmt() .with_ansi(false) + .with_max_level(tracing::Level::WARN) .with_writer(std::io::sink) .init(); } diff --git a/PIMELauncher/src/protocol.rs b/PIMELauncher/src/protocol.rs index 7b2d1d5b8..eeef7b56d 100644 --- a/PIMELauncher/src/protocol.rs +++ b/PIMELauncher/src/protocol.rs @@ -26,15 +26,9 @@ pub fn parse_client_handshake(first_line: &str) -> Result { /// Expects the format `PIME_MSG||`. /// Returns `Some((client_id, payload))` if valid. pub fn parse_backend_output(line: &str) -> Option<(String, String)> { - if line.starts_with("PIME_MSG|") { - let parts: Vec<&str> = line.splitn(3, '|').collect(); - if parts.len() == 3 { - let client_id = parts[1].to_string(); - let payload = parts[2].to_string(); - return Some((client_id, payload)); - } - } - None + let rest = line.strip_prefix("PIME_MSG|")?; + let sep = rest.find('|')?; + Some((rest[..sep].to_string(), rest[sep + 1..].to_string())) } /// Formats a message to be sent to a backend process. diff --git a/PIMETextService/PIMEClient.cpp b/PIMETextService/PIMEClient.cpp index 902b63436..b4ef09ff0 100644 --- a/PIMETextService/PIMEClient.cpp +++ b/PIMETextService/PIMEClient.cpp @@ -37,6 +37,11 @@ using json = nlohmann::json; namespace PIME { +static constexpr const char* kDayiProfileGuid = "{e6943374-70f5-4540-aa0f-3205c7dcca84}"; +static constexpr const char* kChewingProfileGuid = "{f80736aa-28db-423a-92c9-5540f501c939}"; +static constexpr const char* kChecjProfileGuid = "{f828d2dc-81be-466e-9cfe-24bb03172693}"; +static constexpr const char* kCheliuProfileGuid = "{72844b94-5908-4674-8626-4353755bc5db}"; + static std::string uuidToString(const UUID& uuid) { std::string result; LPOLESTR buf = nullptr; @@ -54,6 +59,209 @@ bool uuidFromString(const char* uuidStr, UUID& result) { return SUCCEEDED(CLSIDFromString(utf16UuidStr.c_str(), &result)); } +static bool parseHexColor(const json& value, COLORREF& color) { + if (!value.is_string()) + return false; + std::string text = value.get(); + if (text.size() != 7 || text[0] != '#') + return false; + char* end = nullptr; + unsigned long rgb = std::strtoul(text.c_str() + 1, &end, 16); + if (end == nullptr || *end != '\0') + return false; + color = RGB((rgb >> 16) & 0xff, (rgb >> 8) & 0xff, rgb & 0xff); + return true; +} + +static void parseHexColorMember(const json& object, const char* name, COLORREF& color) { + auto it = object.find(name); + if (it != object.end()) + parseHexColor(*it, color); +} + +static std::string normalizedThemeName(const std::string& theme) { + std::string normalized; + for (unsigned char ch : theme) { + if (std::isalnum(ch)) + normalized.push_back((char)std::tolower(ch)); + } + return normalized; +} + +static int candidateKeyStyleValue(const std::string& style) { + const std::string name = normalizedThemeName(style); + if (name == "divider" || name == "dividerslim") + return Ime::CandidateWindow::KeyStyleDivider; + if (name == "quiet" || name == "quietkey") + return Ime::CandidateWindow::KeyStyleQuiet; + if (name == "badge" || name == "badgeminimal") + return Ime::CandidateWindow::KeyStyleBadgeMinimal; + if (name == "accentdot") + return Ime::CandidateWindow::KeyStyleAccentDot; + if (name == "rail" || name == "railmarker") + return Ime::CandidateWindow::KeyStyleRail; + if (name == "monospace" || name == "monospaceslot") + return Ime::CandidateWindow::KeyStyleMonospaceSlot; + if (name == "wordfirst") + return Ime::CandidateWindow::KeyStyleWordFirst; + if (name == "softcapsule") + return Ime::CandidateWindow::KeyStyleSoftCapsule; + if (name == "lefttag") + return Ime::CandidateWindow::KeyStyleLeftTag; + if (name == "glow" || name == "glowkey") + return Ime::CandidateWindow::KeyStyleGlowKey; + if (name == "microtab") + return Ime::CandidateWindow::KeyStyleMicroTab; + if (name == "wordanchor" || name == "underline" || name == "rule" || name == "rulekey") + return Ime::CandidateWindow::KeyStyleWordAnchor; + return Ime::CandidateWindow::KeyStyleKeycap; +} + +static int candidateMessageStyleValue(const std::string& style) { + const std::string name = normalizedThemeName(style); + if (name == "bar") + return Ime::CandidateWindow::MessageStyleBar; + if (name == "dot") + return Ime::CandidateWindow::MessageStyleDot; + return Ime::CandidateWindow::MessageStyleBadge; +} + +static void candidateThemeColors(const std::string& theme, + COLORREF& panelBackground, + COLORREF& panelBorder, + COLORREF& textPrimary, + COLORREF& textSecondary, + COLORREF& highlightBackground, + COLORREF& highlightBorder, + COLORREF& highlightText) { + const std::string name = normalizedThemeName(theme); + if (name == "nightcomfort" || name == "night" || name == "dark") { + panelBackground = RGB(27, 28, 32); + panelBorder = RGB(74, 77, 87); + textPrimary = RGB(229, 232, 238); + textSecondary = RGB(169, 175, 186); + highlightBackground = RGB(64, 95, 138); + highlightBorder = RGB(94, 126, 167); + highlightText = RGB(238, 244, 255); + } + else if (name == "softfocus" || name == "soft") { + panelBackground = RGB(25, 29, 33); + panelBorder = RGB(68, 82, 90); + textPrimary = RGB(228, 235, 238); + textSecondary = RGB(168, 181, 186); + highlightBackground = RGB(63, 111, 107); + highlightBorder = RGB(106, 153, 147); + highlightText = RGB(236, 251, 248); + } + else if (name == "warmgray" || name == "warmgrey") { + panelBackground = RGB(32, 32, 29); + panelBorder = RGB(88, 85, 75); + textPrimary = RGB(235, 231, 220); + textSecondary = RGB(183, 177, 163); + highlightBackground = RGB(95, 104, 77); + highlightBorder = RGB(135, 147, 111); + highlightText = RGB(247, 243, 231); + } + else if (name == "graphite") { + panelBackground = RGB(18, 20, 26); + panelBorder = RGB(68, 74, 87); + textPrimary = RGB(243, 245, 250); + textSecondary = RGB(174, 181, 196); + highlightBackground = RGB(65, 105, 215); + highlightBorder = RGB(111, 146, 235); + highlightText = RGB(237, 243, 255); + } + else if (name == "slateteal" || name == "teal") { + panelBackground = RGB(21, 32, 39); + panelBorder = RGB(63, 90, 100); + textPrimary = RGB(240, 248, 251); + textSecondary = RGB(165, 186, 194); + highlightBackground = RGB(47, 127, 159); + highlightBorder = RGB(96, 173, 200); + highlightText = RGB(233, 251, 255); + } + else if (name == "olive") { + panelBackground = RGB(23, 27, 22); + panelBorder = RGB(75, 89, 65); + textPrimary = RGB(244, 247, 239); + textSecondary = RGB(180, 189, 167); + highlightBackground = RGB(93, 127, 54); + highlightBorder = RGB(145, 185, 98); + highlightText = RGB(244, 255, 232); + } + else if (name == "plum") { + panelBackground = RGB(29, 23, 33); + panelBorder = RGB(96, 75, 102); + textPrimary = RGB(251, 244, 255); + textSecondary = RGB(192, 173, 202); + highlightBackground = RGB(122, 85, 184); + highlightBorder = RGB(170, 131, 230); + highlightText = RGB(251, 243, 255); + } + else if (name == "amber") { + panelBackground = RGB(33, 26, 18); + panelBorder = RGB(104, 83, 58); + textPrimary = RGB(255, 248, 237); + textSecondary = RGB(207, 189, 164); + highlightBackground = RGB(154, 103, 48); + highlightBorder = RGB(213, 154, 88); + highlightText = RGB(255, 243, 222); + } + else if (name == "paper") { + panelBackground = RGB(251, 250, 246); + panelBorder = RGB(183, 172, 156); + textPrimary = RGB(39, 33, 25); + textSecondary = RGB(120, 107, 93); + highlightBackground = RGB(49, 95, 135); + highlightBorder = RGB(36, 73, 103); + highlightText = RGB(247, 251, 255); + } + else if (name == "mistlight" || name == "mist") { + panelBackground = RGB(233, 237, 240); + panelBorder = RGB(168, 179, 188); + textPrimary = RGB(36, 48, 58); + textSecondary = RGB(102, 114, 125); + highlightBackground = RGB(95, 127, 148); + highlightBorder = RGB(75, 104, 123); + highlightText = RGB(247, 251, 253); + } + else if (name == "sepiadim" || name == "sepia") { + panelBackground = RGB(40, 37, 31); + panelBorder = RGB(93, 86, 74); + textPrimary = RGB(235, 226, 211); + textSecondary = RGB(185, 173, 154); + highlightBackground = RGB(109, 101, 71); + highlightBorder = RGB(149, 138, 99); + highlightText = RGB(248, 239, 217); + } + else { + panelBackground = RGB(247, 249, 252); + panelBorder = RGB(174, 184, 203); + textPrimary = RGB(24, 34, 53); + textSecondary = RGB(101, 113, 135); + highlightBackground = RGB(47, 110, 234); + highlightBorder = RGB(29, 86, 196); + highlightText = RGB(255, 255, 255); + } +} + +static bool usesModernCandidateDefault(const std::string& guid) { + return guid == kDayiProfileGuid || + guid == kChewingProfileGuid || + guid == kChecjProfileGuid || + guid == kCheliuProfileGuid; +} + +static bool shouldHoldKeyWhenBackendUnavailable(const std::string& guid, Ime::KeyEvent& keyEvent) { + if (!usesModernCandidateDefault(guid)) + return false; + + if (keyEvent.isKeyDown(VK_CONTROL) || keyEvent.isKeyDown(VK_MENU)) + return false; + + return keyEvent.charCode() >= 0x20; +} + Client::Client(TextService* service, REFIID langProfileGuid): textService_(service), pipe_(INVALID_HANDLE_VALUE), @@ -62,6 +270,22 @@ Client::Client(TextService* service, REFIID langProfileGuid): guid_{ uuidToString(langProfileGuid) }, shouldWaitConnection_{ true }, ioEvent_{ CreateEvent(NULL, TRUE, FALSE, NULL) } { + if (usesModernCandidateDefault(guid_)) { + textService_->setCandPerRow(6); + textService_->setCandidateEdgeAvoidance(true); + textService_->setCandidateTheme( + RGB(27, 28, 32), + RGB(74, 77, 87), + RGB(229, 232, 238), + RGB(169, 175, 186), + RGB(64, 95, 138), + RGB(94, 126, 167), + RGB(238, 244, 255)); + textService_->setCandidateSpacing(6, 4, 6); + textService_->setCandidateStableWidth(true, 286); + textService_->setCandidateMaxWidth(true, 300); + textService_->setCandidateModernStyle(true); + } } Client::~Client(void) { @@ -95,6 +319,9 @@ bool Client::handleRpcResponse(json& msg, Ime::EditSession* session) { } void Client::updateUI(json& data) { + bool pendingModernStyle = false; + bool hasModernStyle = false; + for (auto it = data.begin(); it != data.end(); ++it) { const std::string& name = it.key(); const json& value = it.value(); @@ -111,6 +338,74 @@ void Client::updateUI(json& data) { else if (value.is_boolean() && name == "candUseCursor") { textService_->setCandUseCursor(value.get()); } + else if (value.is_boolean() && name == "candidateModernStyle") { + // Defer until theme/spacing are applied so applyCandidateWindowStyle() + // runs once with the final state instead of triggering early with stale colors. + pendingModernStyle = value.get(); + hasModernStyle = true; + } + else if (value.is_boolean() && name == "candidateEdgeAvoidance") { + textService_->setCandidateEdgeAvoidance(value.get()); + } + else if (value.is_string() && name == "candidateKeyStyle") { + textService_->setCandidateKeyStyle(candidateKeyStyleValue(value.get())); + } + else if (value.is_string() && name == "candidateMessageStyle") { + textService_->setCandidateMessageStyle(candidateMessageStyleValue(value.get())); + } + } + + // Apply theme colors (preset) with optional per-key overrides from candidateColors + auto themeIt = data.find("candidateTheme"); + auto colorsIt = data.find("candidateColors"); + if ((themeIt != data.end() && themeIt->is_string()) || + (colorsIt != data.end() && colorsIt->is_object())) { + COLORREF panelBg, panelBorder, textPrimary, textSecondary, highlightBg, highlightBorder, highlightText; + std::string theme = (themeIt != data.end() && themeIt->is_string()) ? themeIt->get() : "light"; + candidateThemeColors(theme, panelBg, panelBorder, textPrimary, textSecondary, highlightBg, highlightBorder, highlightText); + if (colorsIt != data.end() && colorsIt->is_object()) { + parseHexColorMember(*colorsIt, "panelBackground", panelBg); + parseHexColorMember(*colorsIt, "panelBorder", panelBorder); + parseHexColorMember(*colorsIt, "textPrimary", textPrimary); + parseHexColorMember(*colorsIt, "textSecondary", textSecondary); + parseHexColorMember(*colorsIt, "highlightBackground", highlightBg); + parseHexColorMember(*colorsIt, "highlightBorder", highlightBorder); + parseHexColorMember(*colorsIt, "highlightText", highlightText); + } + textService_->setCandidateTheme(panelBg, panelBorder, textPrimary, textSecondary, highlightBg, highlightBorder, highlightText); + } + + // Apply spacing style + auto styleIt = data.find("candidateStyle"); + if (styleIt != data.end() && styleIt->is_object()) { + int contentMargin = styleIt->value("contentMargin", 8); + int textMargin = styleIt->value("textMargin", 6); + int borderRadius = styleIt->value("borderRadius", 8); + textService_->setCandidateSpacing(contentMargin, textMargin, borderRadius); + } + + auto stableIt = data.find("candidateStableWidth"); + auto minWidthIt = data.find("candidateMinWidth"); + if ((stableIt != data.end() && stableIt->is_boolean()) || + (minWidthIt != data.end() && minWidthIt->is_number_integer())) { + bool stableWidth = stableIt != data.end() && stableIt->is_boolean() ? stableIt->get() : false; + int minWidth = minWidthIt != data.end() && minWidthIt->is_number_integer() ? minWidthIt->get() : 0; + textService_->setCandidateStableWidth(stableWidth, minWidth); + } + + auto wrapIt = data.find("candidateWrapToMaxWidth"); + auto maxWidthIt = data.find("candidateMaxWidth"); + if ((wrapIt != data.end() && wrapIt->is_boolean()) || + (maxWidthIt != data.end() && maxWidthIt->is_number_integer())) { + bool wrapToMaxWidth = wrapIt != data.end() && wrapIt->is_boolean() ? wrapIt->get() : false; + int maxWidth = maxWidthIt != data.end() && maxWidthIt->is_number_integer() ? maxWidthIt->get() : 0; + textService_->setCandidateMaxWidth(wrapToMaxWidth, maxWidth); + } + + // Apply modernStyle last: triggers the final applyCandidateWindowStyle() with + // theme and spacing already in their new state. + if (hasModernStyle) { + textService_->setCandidateModernStyle(pendingModernStyle); } } @@ -130,6 +425,12 @@ void Client::updateMessageWindow(json& msg, Ime::EditSession* session, bool& end auto& message = showMessageVal["message"]; auto& duration = showMessageVal["duration"]; if (message.is_string() && duration.is_number_integer()) { + if (textService_->candidateModernStyle()) { + textService_->hideMessage(); + msg["candidateMessage"] = message.get(); + msg["showCandidates"] = true; + return; + } if (!textService_->isComposing()) { textService_->startComposition(session->context()); endComposition = true; @@ -178,9 +479,11 @@ void Client::updateComposition(json& msg, Ime::EditSession* session, bool& endCo hasCompositionString = true; if (compositionString.empty()) { emptyComposition = true; + if (textService_->isComposing()) { + textService_->setCompositionString(session, L"", 0); + } if (textService_->isComposing() && !textService_->showingCandidates()) { // when the composition buffer is empty and we are not showing the candidate list, end composition. - textService_->setCompositionString(session, L"", 0); endComposition = true; } } @@ -311,6 +614,13 @@ void Client::updateKeyboardStatus(json& msg) { void Client::updateStatus(json& msg, Ime::EditSession* session) { // We need to handle ordering of some types of the requests. // For example, setCompositionCursor() should happen after setCompositionCursor(). + // UI customization must be applied before candidateList creates or refreshes + // the candidate window; otherwise the first rendered window keeps the old UI. + auto& customizeUIVal = msg["customizeUI"]; + if (customizeUIVal.is_object()) { + updateUI(customizeUIVal); + } + updateSelectionKeys(msg); // show message @@ -333,20 +643,32 @@ void Client::updateStatus(json& msg, Ime::EditSession* session) { // keyboard status updateKeyboardStatus(msg); - - // other configurations - auto& customizeUIVal = msg["customizeUI"]; - if (customizeUIVal.is_object()) { - // customize the UI - updateUI(customizeUIVal); - } } void Client::updateCandidateList(json& msg, Ime::EditSession* session) { // handle candidate list const auto& showCandidatesVal = msg["showCandidates"]; + const auto& candidateMessageVal = msg["candidateMessage"]; + const auto& candidateMessageStyleVal = msg["candidateMessageStyle"]; + bool hasCandidateMessage = false; + if (candidateMessageVal.is_string()) { + std::wstring message = utf8ToUtf16(candidateMessageVal.get().c_str()); + hasCandidateMessage = !message.empty(); + if (candidateMessageStyleVal.is_string()) { + textService_->setCandidateMessageDisplayStyle(candidateMessageStyleValue(candidateMessageStyleVal.get())); + } + else { + textService_->resetCandidateMessageDisplayStyle(); + } + textService_->setCandidateMessage(message); + } + else if (showCandidatesVal.is_boolean()) { + textService_->resetCandidateMessageDisplayStyle(); + textService_->setCandidateMessage(L""); + } + if (showCandidatesVal.is_boolean()) { - if (showCandidatesVal.get()) { + if (showCandidatesVal.get() || hasCandidateMessage) { // start composition if we are not composing. // this is required to get correctly position the candidate window if (!textService_->isComposing()) { @@ -355,24 +677,60 @@ void Client::updateCandidateList(json& msg, Ime::EditSession* session) { textService_->showCandidates(session); } else { + textService_->setCandidateMessage(L""); textService_->hideCandidates(); } } + else if (hasCandidateMessage) { + if (!textService_->isComposing()) { + textService_->startComposition(session->context()); + } + textService_->showCandidates(session); + } + + // parse candidateHeader before candidateList so candidateHeader_ is correct + // when updateCandidates() calls setHeader(candidateHeader_) + const auto& candidateHeaderVal = msg["candidateHeader"]; + if (candidateHeaderVal.is_string()) { + std::wstring header = utf8ToUtf16(candidateHeaderVal.get().c_str()); + textService_->setCandidateHeader(header); + } + else if (showCandidatesVal.is_boolean() && !showCandidatesVal.get() && !hasCandidateMessage) { + textService_->setCandidateHeader(L""); + } + + const auto& candidatePageInfoVal = msg["candidatePageInfo"]; + if (candidatePageInfoVal.is_string()) { + std::wstring info = utf8ToUtf16(candidatePageInfoVal.get().c_str()); + textService_->setCandidatePageInfo(info); + } + else if (showCandidatesVal.is_boolean() && !showCandidatesVal.get() && !hasCandidateMessage) { + textService_->setCandidatePageInfo(L""); + } const auto& candidateListVal = msg["candidateList"]; if (candidateListVal.is_array()) { + if (!hasCandidateMessage) { + textService_->setCandidateMessage(L""); + } // handle candidates // FIXME: directly access private member is dirty!!! vector& candidates = textService_->candidates_; candidates.clear(); for (const auto& candidate : candidateListVal) { - candidates.emplace_back(utf8ToUtf16(candidate.get().c_str())); + if (candidate.is_string()) { + candidates.emplace_back(utf8ToUtf16(candidate.get().c_str())); + } } textService_->updateCandidates(session); - if (!showCandidatesVal.get()) { + if (showCandidatesVal.is_boolean() && !showCandidatesVal.get() && !hasCandidateMessage) { textService_->hideCandidates(); } } + else if (hasCandidateMessage) { + textService_->candidates_.clear(); + textService_->updateCandidates(session); + } const auto& candidateCursorVal = msg["candidateCursor"]; if (candidateCursorVal.is_number_integer()) { @@ -410,11 +768,11 @@ bool Client::filterKeyDown(Ime::KeyEvent& keyEvent) { addKeyEventToRpcRequest(req, keyEvent); json ret; - callRpcMethod(req, ret); + callRpcMethod(req, ret, 250); if (handleRpcResponse(ret)) { - return ret["return"].get(); + return ret.value("return", false); } - return false; + return shouldHoldKeyWhenBackendUnavailable(guid_, keyEvent); } bool Client::onKeyDown(Ime::KeyEvent& keyEvent, Ime::EditSession* session) { @@ -422,11 +780,11 @@ bool Client::onKeyDown(Ime::KeyEvent& keyEvent, Ime::EditSession* session) { addKeyEventToRpcRequest(req, keyEvent); json ret; - callRpcMethod(req, ret); + callRpcMethod(req, ret, 250); if (handleRpcResponse(ret, session)) { - return ret["return"].get(); + return ret.value("return", false); } - return false; + return shouldHoldKeyWhenBackendUnavailable(guid_, keyEvent); } bool Client::filterKeyUp(Ime::KeyEvent& keyEvent) { @@ -434,9 +792,9 @@ bool Client::filterKeyUp(Ime::KeyEvent& keyEvent) { addKeyEventToRpcRequest(req, keyEvent); json ret; - callRpcMethod(req, ret); + callRpcMethod(req, ret, 250); if (handleRpcResponse(ret)) { - return ret["return"].get(); + return ret.value("return", false); } return false; } @@ -446,9 +804,9 @@ bool Client::onKeyUp(Ime::KeyEvent& keyEvent, Ime::EditSession* session) { addKeyEventToRpcRequest(req, keyEvent); json ret; - callRpcMethod(req, ret); + callRpcMethod(req, ret, 250); if (handleRpcResponse(ret, session)) { - return ret["return"].get(); + return ret.value("return", false); } return false; } @@ -462,7 +820,7 @@ bool Client::onPreservedKey(const GUID& guid) { json ret; callRpcMethod(req, ret); if (handleRpcResponse(ret)) { - return ret["return"].get(); + return ret.value("return", false); } } return false; @@ -476,7 +834,7 @@ bool Client::onCommand(UINT id, Ime::TextService::CommandType type) { json ret; callRpcMethod(req, ret); if (handleRpcResponse(ret)) { - return ret["return"].get(); + return ret.value("return", false); } return false; } @@ -634,7 +992,10 @@ json Client::createRpcRequest(const char* methodName) { } bool Client::callPipeIO(bool isRead, void *buffer, DWORD size, DWORD *rlen, int timeoutMs) { - if (!ioEvent_) { + if (!ioEvent_ || ioEvent_ == INVALID_HANDLE_VALUE) { + ioEvent_ = CreateEvent(NULL, TRUE, FALSE, NULL); + } + if (!ioEvent_ || ioEvent_ == INVALID_HANDLE_VALUE) { return false; } @@ -665,19 +1026,18 @@ bool Client::callPipeIO(bool isRead, void *buffer, DWORD size, DWORD *rlen, int return ok; } -bool Client::callRpcPipe(HANDLE pipe, const std::string& serializedRequest, std::string& serializedReply) { +bool Client::callRpcPipe(HANDLE pipe, const std::string& serializedRequest, std::string& serializedReply, int timeoutMs) { std::string request = serializedRequest; if (request.empty() || request.back() != '\n') { request += '\n'; } - const int timeoutMs = 2000; DWORD wlen = 0; if (!callPipeIO(false, (void*)request.data(), (DWORD)request.size(), &wlen, timeoutMs)) { return false; } - char buf[1024]; + char buf[8192]; DWORD rlen = 0; while (true) { // Check if we already have a full line in the buffer @@ -697,7 +1057,7 @@ bool Client::callRpcPipe(HANDLE pipe, const std::string& serializedRequest, std: // send the request to the server // a sequence number will be added to the req object automatically. -bool Client::callRpcMethod(json& request, json & response) { +bool Client::callRpcMethod(json& request, json & response, int timeoutMs) { if (shouldWaitConnection_ && !waitForRpcConnection()) { return false; } @@ -710,7 +1070,7 @@ bool Client::callRpcMethod(json& request, json & response) { std::string serializedResponse; bool success = false; - if (callRpcPipe(pipe_, serializedRequest, serializedResponse)) { + if (callRpcPipe(pipe_, serializedRequest, serializedResponse, timeoutMs)) { try { response = json::parse(serializedResponse); success = true; @@ -771,9 +1131,9 @@ bool Client::waitForRpcConnection() { } wstring serverPipeName = getPipeName(L"Launcher"); - for (int attempt = 0; pipe_ == INVALID_HANDLE_VALUE && attempt < 5; ++attempt) { + for (int attempt = 0; pipe_ == INVALID_HANDLE_VALUE && attempt < 3; ++attempt) { // try to connect to the server - pipe_ = connectPipe(serverPipeName.c_str(), 30000); + pipe_ = connectPipe(serverPipeName.c_str(), 3000); } if (pipe_ != INVALID_HANDLE_VALUE) { @@ -818,9 +1178,9 @@ void Client::closeRpcConnection() { CloseHandle(pipe_); pipe_ = INVALID_HANDLE_VALUE; } - if (ioEvent_ != INVALID_HANDLE_VALUE) { + if (ioEvent_ && ioEvent_ != INVALID_HANDLE_VALUE) { CloseHandle(ioEvent_); - ioEvent_ = INVALID_HANDLE_VALUE; + ioEvent_ = NULL; } readBuffer_.clear(); } diff --git a/PIMETextService/PIMEClient.h b/PIMETextService/PIMEClient.h index 24ff123a6..28de29f29 100644 --- a/PIMETextService/PIMEClient.h +++ b/PIMETextService/PIMEClient.h @@ -79,11 +79,11 @@ class Client HANDLE connectPipe(const wchar_t* pipeName, int timeoutMs); nlohmann::json createRpcRequest(const char* methodName); - bool callRpcMethod(nlohmann::json& request, nlohmann::json& response); + bool callRpcMethod(nlohmann::json& request, nlohmann::json& response, int timeoutMs = 2000); bool isPipeCreatedByPIMEServer(HANDLE pipe); bool waitForRpcConnection(); - bool callRpcPipe(HANDLE pipe, const std::string& serializedRequest, std::string& serializedReply); + bool callRpcPipe(HANDLE pipe, const std::string& serializedRequest, std::string& serializedReply, int timeoutMs = 2000); bool callPipeIO(bool isRead, void *buffer, DWORD size, DWORD *rlen, int timeoutMs); void closeRpcConnection(); diff --git a/PIMETextService/PIMETextService.cpp b/PIMETextService/PIMETextService.cpp index ebef8630e..eb3f274b1 100644 --- a/PIMETextService/PIMETextService.cpp +++ b/PIMETextService/PIMETextService.cpp @@ -26,6 +26,7 @@ #include "PIMEImeModule.h" #include "resource.h" #include +#include #include using namespace std; @@ -45,7 +46,26 @@ TextService::TextService(ImeModule* module): candPerRow_(10), selKeys_(L"1234567890"), candUseCursor_(false), - candFontSize_(12) { + candFontSize_(12), + candidateModernStyle_(false), + candidateEdgeAvoidance_(true), + candidatePanelBackground_(RGB(255, 255, 255)), + candidatePanelBorder_(RGB(218, 221, 227)), + candidateTextPrimary_(RGB(32, 36, 42)), + candidateTextSecondary_(RGB(107, 114, 128)), + candidateHighlightBackground_(RGB(220, 235, 255)), + candidateHighlightBorder_(RGB(156, 199, 255)), + candidateHighlightText_(RGB(11, 58, 117)), + candidateContentMargin_(8), + candidateTextMargin_(6), + candidateBorderRadius_(8), + candidateKeyStyle_(Ime::CandidateWindow::KeyStyleKeycap), + candidateMessageStyle_(Ime::CandidateWindow::MessageStyleBadge), + candidateMessageDisplayStyle_(Ime::CandidateWindow::MessageStyleBadge), + candidateStableWidth_(false), + candidateMinWidth_(0), + candidateWrapToMaxWidth_(false), + candidateMaxWidth_(0) { // font for candidate and mesasge windows font_ = (HFONT)GetStockObject(DEFAULT_GUI_FONT); @@ -232,12 +252,13 @@ void TextService::createCandidateWindow(Ime::EditSession* session) { candidateWindow_->Release(); // decrease ref count caused by new candidateWindow_->setFont(font_); + applyCandidateWindowStyle(); auto elementMgr = Ime::ComPtr::queryFrom(threadMgr()); if (elementMgr) { BOOL pbShow = false; if (validCandidateListElementId_ = (elementMgr->BeginUIElement(candidateWindow_, &pbShow, &candidateListElementId_) == S_OK)) { - candidateWindow_->Show(pbShow); + candidateWindow_->Show(TRUE); } } } @@ -246,6 +267,7 @@ void TextService::createCandidateWindow(Ime::EditSession* session) { void TextService::updateCandidates(Ime::EditSession* session) { createCandidateWindow(session); candidateWindow_->clear(); + candidateWindow_->setTextRows(candidateMessage_, candidateHeader_, candidatePageInfo_); // FIXME: is this the right place to do it? if (updateFont_) { @@ -277,12 +299,7 @@ void TextService::updateCandidates(Ime::EditSession* session) { candidateWindow_->recalculateSize(); candidateWindow_->refresh(); - RECT textRect; - // get the position of composition area from TSF - if (selectionRect(session, &textRect)) { - // FIXME: where should we put the candidate window? - candidateWindow_->move(textRect.left, textRect.bottom); - } + moveCandidateWindow(session); if (validCandidateListElementId_) { auto elementMgr = Ime::ComPtr::queryFrom(threadMgr()); @@ -303,6 +320,102 @@ void TextService::updateCandidatesWindow(Ime::EditSession* session) { } } +void TextService::setCandidateTheme(COLORREF panelBackground, + COLORREF panelBorder, + COLORREF textPrimary, + COLORREF textSecondary, + COLORREF highlightBackground, + COLORREF highlightBorder, + COLORREF highlightText) { + if ( + candidatePanelBackground_ == panelBackground && + candidatePanelBorder_ == panelBorder && + candidateTextPrimary_ == textPrimary && + candidateTextSecondary_ == textSecondary && + candidateHighlightBackground_ == highlightBackground && + candidateHighlightBorder_ == highlightBorder && + candidateHighlightText_ == highlightText + ) + return; + candidatePanelBackground_ = panelBackground; + candidatePanelBorder_ = panelBorder; + candidateTextPrimary_ = textPrimary; + candidateTextSecondary_ = textSecondary; + candidateHighlightBackground_ = highlightBackground; + candidateHighlightBorder_ = highlightBorder; + candidateHighlightText_ = highlightText; + applyCandidateWindowStyle(); +} + +void TextService::setCandidateSpacing(int contentMargin, int textMargin, int borderRadius) { + if ( + candidateContentMargin_ == contentMargin && + candidateTextMargin_ == textMargin && + candidateBorderRadius_ == borderRadius + ) + return; + candidateContentMargin_ = contentMargin; + candidateTextMargin_ = textMargin; + candidateBorderRadius_ = borderRadius; + applyCandidateWindowStyle(); +} + +void TextService::applyCandidateWindowStyle() { + if (!candidateWindow_) + return; + candidateWindow_->setModernStyle(candidateModernStyle_); + candidateWindow_->setTheme(candidatePanelBackground_, + candidatePanelBorder_, + candidateTextPrimary_, + candidateTextSecondary_, + candidateHighlightBackground_, + candidateHighlightBorder_, + candidateHighlightText_); + candidateWindow_->setSpacing(candidateContentMargin_, candidateTextMargin_, candidateBorderRadius_); + candidateWindow_->setKeyStyle(candidateKeyStyle_); + candidateWindow_->setMessageStyle(candidateMessageDisplayStyle_); + candidateWindow_->setStableWidth(candidateStableWidth_, candidateMinWidth_); + candidateWindow_->setMaxWidth(candidateWrapToMaxWidth_, candidateMaxWidth_); +} + +void TextService::moveCandidateWindow(Ime::EditSession* session) { + if (!candidateWindow_) + return; + + RECT textRect; + if (!selectionRect(session, &textRect)) + return; + + int width = 0; + int height = 0; + candidateWindow_->size(&width, &height); + + int x = textRect.left; + int y = textRect.bottom; + if (candidateEdgeAvoidance_) { + RECT desired = { textRect.left, textRect.bottom, textRect.left + width, textRect.bottom + height }; + HMONITOR monitor = ::MonitorFromRect(&desired, MONITOR_DEFAULTTONEAREST); + MONITORINFO mi; + mi.cbSize = sizeof(mi); + if (::GetMonitorInfo(monitor, &mi)) { + const RECT& work = mi.rcWork; + if (x + width > work.right) + x = work.right - width; + if (x < work.left) + x = work.left; + + if (y + height > work.bottom && textRect.top - height >= work.top) + y = textRect.top - height; + else if (y + height > work.bottom) + y = work.bottom - height; + if (y < work.top) + y = work.top; + } + } + + candidateWindow_->move(x, y); +} + void TextService::refreshCandidates() { if (validCandidateListElementId_) { auto elementMgr = Ime::ComPtr::queryFrom(threadMgr()); diff --git a/PIMETextService/PIMETextService.h b/PIMETextService/PIMETextService.h index 65d7b37ab..0660ffa29 100644 --- a/PIMETextService/PIMETextService.h +++ b/PIMETextService/PIMETextService.h @@ -122,6 +122,88 @@ class TextService: public Ime::TextService { return showingCandidates_; } + const std::wstring& candidateHeader() const { + return candidateHeader_; + } + + void setCandidateHeader(const std::wstring& header) { + candidateHeader_ = header; + } + + void setCandidatePageInfo(const std::wstring& info) { + candidatePageInfo_ = info; + } + + void setCandidateMessage(const std::wstring& message) { + candidateMessage_ = message; + } + + void setCandidateTheme(COLORREF panelBackground, + COLORREF panelBorder, + COLORREF textPrimary, + COLORREF textSecondary, + COLORREF highlightBackground, + COLORREF highlightBorder, + COLORREF highlightText); + + void setCandidateSpacing(int contentMargin, int textMargin, int borderRadius); + + void setCandidateKeyStyle(int keyStyle) { + if (candidateKeyStyle_ == keyStyle) + return; + candidateKeyStyle_ = keyStyle; + applyCandidateWindowStyle(); + } + + void setCandidateMessageStyle(int messageStyle) { + if (candidateMessageStyle_ == messageStyle && candidateMessageDisplayStyle_ == messageStyle) + return; + candidateMessageStyle_ = messageStyle; + candidateMessageDisplayStyle_ = messageStyle; + applyCandidateWindowStyle(); + } + + void setCandidateMessageDisplayStyle(int messageStyle) { + if (candidateMessageDisplayStyle_ == messageStyle) + return; + candidateMessageDisplayStyle_ = messageStyle; + if (candidateWindow_) + candidateWindow_->setMessageStyle(candidateMessageDisplayStyle_); + } + + void resetCandidateMessageDisplayStyle() { + setCandidateMessageDisplayStyle(candidateMessageStyle_); + } + + void setCandidateStableWidth(bool enabled, int minWidth) { + if (candidateStableWidth_ == enabled && candidateMinWidth_ == minWidth) + return; + candidateStableWidth_ = enabled; + candidateMinWidth_ = minWidth; + applyCandidateWindowStyle(); + } + + void setCandidateMaxWidth(bool wrapToMaxWidth, int maxWidth) { + if (candidateWrapToMaxWidth_ == wrapToMaxWidth && candidateMaxWidth_ == maxWidth) + return; + candidateWrapToMaxWidth_ = wrapToMaxWidth; + candidateMaxWidth_ = maxWidth; + applyCandidateWindowStyle(); + } + + void setCandidateEdgeAvoidance(bool enabled) { + candidateEdgeAvoidance_ = enabled; + } + + void setCandidateModernStyle(bool enabled) { + candidateModernStyle_ = enabled; + applyCandidateWindowStyle(); + } + + bool candidateModernStyle() const { + return candidateModernStyle_; + } + // candidate window void showCandidates(Ime::EditSession* session); void updateCandidates(Ime::EditSession* session); @@ -144,6 +226,8 @@ class TextService: public Ime::TextService { void updateLangButtons(); // update status of language bar buttons void createCandidateWindow(Ime::EditSession* session); + void applyCandidateWindowStyle(); + void moveCandidateWindow(Ime::EditSession* session); int candFontHeight(); void closeClient(); @@ -162,7 +246,29 @@ class TextService: public Ime::TextService { std::wstring selKeys_; bool candUseCursor_; std::wstring candFontName_; + std::wstring candidateHeader_; + std::wstring candidatePageInfo_; + std::wstring candidateMessage_; int candFontSize_; + bool candidateModernStyle_; + bool candidateEdgeAvoidance_; + COLORREF candidatePanelBackground_; + COLORREF candidatePanelBorder_; + COLORREF candidateTextPrimary_; + COLORREF candidateTextSecondary_; + COLORREF candidateHighlightBackground_; + COLORREF candidateHighlightBorder_; + COLORREF candidateHighlightText_; + int candidateContentMargin_; + int candidateTextMargin_; + int candidateBorderRadius_; + int candidateKeyStyle_; + int candidateMessageStyle_; + int candidateMessageDisplayStyle_; + bool candidateStableWidth_; + int candidateMinWidth_; + bool candidateWrapToMaxWidth_; + int candidateMaxWidth_; HMENU popupMenu_; diff --git a/libIME2 b/libIME2 index 717b1901a..c54d8a2bd 160000 --- a/libIME2 +++ b/libIME2 @@ -1 +1 @@ -Subproject commit 717b1901a417667405399cfbf25b25664efcf0e4 +Subproject commit c54d8a2bddb4727105be60b9212178b882ddb20f diff --git a/python/cinbase/__init__.py b/python/cinbase/__init__.py index 69c985ef1..90d8e62e7 100644 --- a/python/cinbase/__init__.py +++ b/python/cinbase/__init__.py @@ -68,6 +68,34 @@ ID_PROVERBDICT = 12 ID_OUTPUT_SIMP_CHINESE = 13 +LEGACY_LIGHT_CANDIDATE_COLORS = { + "panelBackground": "#FFFFFF", + "panelBorder": "#DADDE3", + "textPrimary": "#20242A", + "textSecondary": "#6B7280", + "highlightBackground": "#DCEBFF", + "highlightBorder": "#9CC7FF", + "highlightText": "#0B3A75", +} + + +def candidateColorsForTheme(cfg): + colors = getattr(cfg, 'candidateColors', {}) + if not isinstance(colors, dict) or not colors: + return {} + + theme = ''.join(ch.lower() for ch in str(getattr(cfg, 'candidateTheme', '')) if ch.isalnum()) + legacyKeys = set(LEGACY_LIGHT_CANDIDATE_COLORS.keys()) + if set(colors.keys()) == legacyKeys: + legacyColors = True + for key, value in LEGACY_LIGHT_CANDIDATE_COLORS.items(): + if str(colors.get(key, '')).strip().lower() != value.lower(): + legacyColors = False + break + if legacyColors and theme not in ('', 'light'): + return {} + return colors + class CinBase: def __init__(self): @@ -100,6 +128,7 @@ def initTextService(self, cbTS, TextService): cbTS.autoClearCompositionChar = False cbTS.playSoundWhenNonCand = False cbTS.directShowCand = False + cbTS.autoCommitSingleCandidate = False cbTS.directCommitSymbol = False cbTS.directCommitSymbolList = [",", "。", "、", ";", "?", "!"] cbTS.bracketSymbolList = ["「」", "『』", "[]", "【】", "〖〗", "〔〕", "﹝﹞", "()", "﹙﹚", "〈〉", "《》", "<>", "﹤﹥", "{}", "﹛﹜"] @@ -110,6 +139,9 @@ def initTextService(self, cbTS, TextService): cbTS.easySymbolsWithShift = False cbTS.showPhrase = False cbTS.sortByPhrase = False + cbTS.hideComposition = False + cbTS.hideCompositionLabel = '' + cbTS.imeDisplayName = '' cbTS.compositionBufferMode = False cbTS.autoMoveCursorInBrackets = False cbTS.imeReverseLookup = False @@ -266,6 +298,47 @@ def onActivate(self, cbTS): tooltip = "設定", type = "menu" ) + self.customizeCandidateUI(cbTS, force=True) + + + def setModernCandidatePageInfo(self, cbTS, currentCandPage, pagecandidates): + if not getattr(cbTS.cfg, 'candidateModernStyle', False): + return + totalPages = len(pagecandidates) if pagecandidates else 0 + if totalPages > 0: + cbTS.currentReply["candidatePageInfo"] = f"{currentCandPage + 1}/{totalPages}" + else: + cbTS.currentReply["candidatePageInfo"] = "" + + def ensureModernCandidateHeader(self, cbTS): + if not getattr(cbTS.cfg, 'candidateModernStyle', False): + return + + if cbTS.currentReply.get("showCandidates") is False: + return + + wantsCandidateWindow = ( + cbTS.currentReply.get("showCandidates") is True or + "candidateList" in cbTS.currentReply or + "candidateMessage" in cbTS.currentReply + ) + if not wantsCandidateWindow or cbTS.currentReply.get("candidateHeader"): + return + + headerCompositionLabels = { + "chedayi": "大易", + "checj": "酷倉", + "cheliu": "蝦米", + } + label = cbTS.imeDisplayName or headerCompositionLabels.get(cbTS.imeDirName, "") + headerText = cbTS.compositionString or self.compositionHeaderText(cbTS) + if label and headerText: + cbTS.currentReply["candidateHeader"] = label + " " + headerText + else: + cbTS.currentReply["candidateHeader"] = label or headerText + + if "candidatePageInfo" not in cbTS.currentReply: + cbTS.currentReply["candidatePageInfo"] = "" # 使用者離開輸入法 @@ -311,7 +384,7 @@ def filterKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): if cbTS.lastKeyDownTime == 0.0: cbTS.lastKeyDownTime = time.time() - if CinTable.loading: + if CinTable.loading or not getattr(cbTS, 'cin', None): return True # 使用者開始輸入,還沒送出前的編輯區內容稱 composition string @@ -425,7 +498,7 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): charStr = chr(charCode) charStrLow = charStr.lower() - if CinTable.loading: + if CinTable.loading or not getattr(cbTS, 'cin', None): if not cbTS.client.isUiLess: messagestr = '正在載入輸入法碼表,請稍候...' cbTS.isShowMessage = True @@ -1089,8 +1162,8 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): if not cbTS.showmenu: if cbTS.imeDirName == "chedayi": cbTS.selKeys = "'[]-\\" - if not self.candselKeys == "0'[]-\\": - self.candselKeys = "0'[]-\\" + if not self.candselKeys == "␣'[]-\\": + self.candselKeys = "␣'[]-\\" cbTS.TextService.setSelKeys(cbTS, self.candselKeys) cbTS.isSelKeysChanged = True @@ -1370,6 +1443,14 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): if cbTS.langMode == CHINESE_MODE and len(cbTS.compositionChar) >= 1 and not cbTS.menumode and not cbTS.multifunctionmode: cbTS.showmenu = False + # 功能選單會暫時改用數字選字鍵;回到一般大易組字候選時, + # 必須在同一包候選回覆內切回大易選字鍵,避免候選窗殘留 1/2/3。 + if cbTS.imeDirName == "chedayi": + cbTS.selKeys = "'[]-\\" + if self.candselKeys != "␣'[]-\\": + self.candselKeys = "␣'[]-\\" + cbTS.TextService.setSelKeys(cbTS, self.candselKeys) + cbTS.isSelKeysChanged = True if not cbTS.directShowCand and not cbTS.selcandmode: if not cbTS.lastCompositionCharLength == len(cbTS.compositionChar): cbTS.lastCompositionCharLength = len(cbTS.compositionChar) @@ -1667,12 +1748,19 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): if cbTS.sortByPhrase and candidates: candidates = self.sortByPhrase(cbTS, copy.deepcopy(candidates)) - if cbTS.langMode == CHINESE_MODE and cbTS.dayisymbolsmode and len(cbTS.compositionChar) == 1 and (keyCode == VK_SPACE or keyCode == VK_RETURN): + if cbTS.langMode == CHINESE_MODE and cbTS.dayisymbolsmode and len(cbTS.compositionChar) == 1 and (keyCode == VK_SPACE or keyCode == VK_RETURN) and cbTS.cin.isInCharDef(cbTS.compositionChar): candidates = cbTS.cin.getCharDef(cbTS.compositionChar) if cbTS.compositionBufferMode and cbTS.directShowCand: self.removeCompositionBufferString(cbTS, 1, True) - if candidates and not cbTS.phrasemode: + autoCommittedSingleCandidate = False + if candidates and not cbTS.phrasemode and self.shouldAutoCommitSingleCandidate(cbTS, candidates): + self.commitSingleCandidate(cbTS, RCinTable, candidates[0]) + candCursor = 0 + currentCandPage = 0 + autoCommittedSingleCandidate = True + + if candidates and not cbTS.phrasemode and not autoCommittedSingleCandidate: if not cbTS.selcandmode: if not cbTS.directShowCand: # EndKey 處理 (拼音、注音) @@ -1801,8 +1889,18 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): if keyCode == VK_SPACE: cbTS.canUseSpaceAsPageKey = False else: - cbTS.isShowCandidates = True - cbTS.canSetCommitString = True + if len(candidates) == 1 and not cbTS.selcandmode and not cbTS.multifunctionmode and len(cbTS.compositionChar) >= cbTS.maxCharLength and getattr(cbTS, 'autoCommitSingleCandidate', False): + commitStr = candidates[0] + cbTS.lastCommitString = commitStr + self.setOutputString(cbTS, RCinTable, commitStr) + if cbTS.showPhrase and not cbTS.selcandmode: + cbTS.phrasemode = True + self.resetComposition(cbTS) + candCursor = 0 + currentCandPage = 0 + else: + cbTS.isShowCandidates = True + cbTS.canSetCommitString = True if cbTS.isShowCandidates: candCursor = cbTS.candidateCursor # 目前的游標位置 @@ -1824,6 +1922,8 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): if not cbTS.isSelKeysChanged: cbTS.setShowCandidates(True) + self.setModernCandidatePageInfo(cbTS, currentCandPage, pagecandidates) + # 多功能前導字元 if cbTS.multifunctionmode and cbTS.directCommitSymbol and not cbTS.selcandmode: if len(candidates) == 1: @@ -1973,7 +2073,9 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): cbTS.setCandidateCursor(candCursor) cbTS.setCandidatePage(currentCandPage) cbTS.setCandidateList(pagecandidates[currentCandPage]) - else: # 沒有候選字 + self.setModernCandidatePageInfo(cbTS, currentCandPage, pagecandidates) + elif not autoCommittedSingleCandidate: # 沒有候選字 + keepNoCandidateMessageInCandidateWindow = False # 按下空白鍵或 Enter 鍵 if (keyCode == VK_SPACE or keyCode == VK_RETURN) and not cbTS.tempEnglishMode: if len(candidates) == 0: @@ -2011,10 +2113,15 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): cbTS.isShowMessage = True cbTS.showMessage("請輸入 Unicode 編碼...", cbTS.messageDurationTime) else: - if not cbTS.client.isUiLess: + keepNoCandidateMessageInCandidateWindow = self.shouldKeepNoCandidateMessageInCandidateWindow(cbTS) + if keepNoCandidateMessageInCandidateWindow: + cbTS.currentReply["candidateMessage"] = "查無組字..." + cbTS.currentReply["candidatePageInfo"] = "" + cbTS.isShowCandidates = True + elif not cbTS.client.isUiLess: cbTS.isShowMessage = True cbTS.showMessage("查無組字...", cbTS.messageDurationTime) - if cbTS.autoClearCompositionChar: + if cbTS.autoClearCompositionChar and not keepNoCandidateMessageInCandidateWindow: if cbTS.compositionBufferMode: RemoveStringLength = 0 if not cbTS.compositionChar == '': @@ -2026,10 +2133,15 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): elif cbTS.useEndKey and charStr in cbTS.endKeyList: if len(candidates) == 0: if not len(cbTS.compositionChar) == 1 and not cbTS.compositionChar == charStrLow: - if not cbTS.client.isUiLess: + keepNoCandidateMessageInCandidateWindow = self.shouldKeepNoCandidateMessageInCandidateWindow(cbTS) + if keepNoCandidateMessageInCandidateWindow: + cbTS.currentReply["candidateMessage"] = "查無組字..." + cbTS.currentReply["candidatePageInfo"] = "" + cbTS.isShowCandidates = True + elif not cbTS.client.isUiLess: cbTS.isShowMessage = True cbTS.showMessage("查無組字...", cbTS.messageDurationTime) - if cbTS.autoClearCompositionChar: + if cbTS.autoClearCompositionChar and not keepNoCandidateMessageInCandidateWindow: if cbTS.compositionBufferMode: RemoveStringLength = 0 if not cbTS.compositionChar == '': @@ -2039,8 +2151,9 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): if cbTS.playSoundWhenNonCand: winsound.PlaySound('alert', winsound.SND_ASYNC) - cbTS.setShowCandidates(False) - cbTS.isShowCandidates = False + if not keepNoCandidateMessageInCandidateWindow: + cbTS.setShowCandidates(False) + cbTS.isShowCandidates = False # 聯想字模式 if PhraseData.phrase is None: @@ -2203,6 +2316,7 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): if candidates: pagecandidates = list(self.chunks(candidates, cbTS.candPerPage)) cbTS.setCandidateList(pagecandidates[currentCandPage]) + self.setModernCandidatePageInfo(cbTS, currentCandPage, pagecandidates) cbTS.setShowCandidates(True) elif len(cbTS.compositionChar) == 0 and charStr == '`': cbTS.compositionChar += charStr @@ -2262,6 +2376,41 @@ def onKeyDown(self, cbTS, keyEvent, CinTable, RCinTable, HCinTable): #print('Type = ' + cbTS.compositionBufferType) #print(cbTS.compositionBufferChar) + # 特定拆碼輸入法的組字內容要固定顯示在候選窗上方,而不是輸入欄位內。 + # 即使使用者設定檔缺少 hideComposition,也要保留這個 UI 契約。 + headerCompositionLabels = { + "chedayi": "大易", + "checj": "酷倉", + "cheliu": "蝦米", + } + forceHeaderComposition = cbTS.imeDirName in headerCompositionLabels + if cbTS.hideComposition or forceHeaderComposition: + headerText = cbTS.compositionString or self.compositionHeaderText(cbTS) + if headerText: + cbTS.currentReply["compositionString"] = "" + cbTS.currentReply["compositionCursor"] = 0 + if forceHeaderComposition: + label = cbTS.imeDisplayName or headerCompositionLabels[cbTS.imeDirName] + else: + label = cbTS.hideCompositionLabel + cbTS.currentReply["candidateHeader"] = (label + ' ' if label else '') + headerText + if "candidateList" not in cbTS.currentReply: + if forceHeaderComposition and not self.isCompositionCharPrefix(cbTS): + cbTS.currentReply["candidateMessage"] = "查無組字..." + cbTS.currentReply["candidatePageInfo"] = "" + cbTS.setCandidateList([]) + else: + # 沒有候選清單時,也送出清單更新,讓 C++ 同步刷新 header。 + cbTS.setCandidateList(cbTS.candidateList if cbTS.showCandidates and cbTS.candidateList else []) + cbTS.setShowCandidates(True) + if not cbTS.currentReply.get("candidatePageInfo"): + cbTS.currentReply["candidatePageInfo"] = "" + + self.ensureModernCandidateHeader(cbTS) + + if "candidateList" in cbTS.currentReply or "showCandidates" in cbTS.currentReply: + self.customizeCandidateUI(cbTS) + return True # 使用者放開按鍵,在 app 收到前先過濾那些鍵是輸入法需要的。 @@ -2383,6 +2532,11 @@ def onKeyUp(self, cbTS, keyEvent): cbTS.isShowMessage = False cbTS.hideMessageOnKeyUp = False + self.ensureModernCandidateHeader(cbTS) + + if "candidateList" in cbTS.currentReply or "showCandidates" in cbTS.currentReply: + self.customizeCandidateUI(cbTS) + def onPreservedKey(self, cbTS, guid): cbTS.lastKeyDownCode = 0; @@ -2791,11 +2945,71 @@ def sortByPhrase(self, cbTS, candidates): i += 1 return candidates + def shouldAutoCommitSingleCandidate(self, cbTS, candidates): + if not getattr(cbTS, 'autoCommitSingleCandidate', False): + return False + if len(candidates) != 1: + return False + if not cbTS.compositionChar or not cbTS.cin.isInCharDef(cbTS.compositionChar): + return False + if cbTS.cin.hasLongerCharDefPrefix(cbTS.compositionChar): + return False + return not ( + cbTS.selcandmode or cbTS.multifunctionmode or cbTS.tempEnglishMode or cbTS.phrasemode or + cbTS.ctrlsymbolsmode or cbTS.dayisymbolsmode or cbTS.fullsymbolsmode or + cbTS.homophonemode or cbTS.isWildcardChardefs + ) + + def commitSingleCandidate(self, cbTS, RCinTable, commitStr): + cbTS.lastCommitString = commitStr + self.setOutputString(cbTS, RCinTable, commitStr) + if cbTS.showPhrase and not cbTS.selcandmode: + cbTS.phrasemode = True + self.resetComposition(cbTS) + # List 分段 def chunks(self, l, n): for i in range(0, len(l), n): yield l[i:i+n] + def compositionHeaderText(self, cbTS): + compositionChar = getattr(cbTS, 'compositionChar', '') + if not compositionChar: + return '' + + if getattr(cbTS, 'imeDirName', '') == "chedayi": + dayiSymbolChar = getattr(cbTS, 'DayiSymbolChar', '') + if getattr(cbTS, 'dayisymbolsmode', False) and compositionChar == dayiSymbolChar: + return getattr(cbTS, 'DayiSymbolString', compositionChar) + + text = '' + cin = getattr(cbTS, 'cin', None) + for cStr in compositionChar: + if cin is not None and cin.isInKeyName(cStr): + text += cin.getKeyName(cStr) + elif getattr(cbTS, 'supportWildcard', False) and getattr(cbTS, 'selWildcardChar', '') == "*" and cStr == "*": + text += "*" + else: + text += cStr + return text + + def isCompositionCharPrefix(self, cbTS): + compositionChar = getattr(cbTS, 'compositionChar', '') + if not compositionChar: + return False + + cin = getattr(cbTS, 'cin', None) + if cin is None: + return True + + if hasattr(cin, 'isCharDefPrefix'): + return cin.isCharDefPrefix(compositionChar) + + return cin.isInCharDef(compositionChar) or bool(cin.haveNextCharDef(compositionChar)) + + def shouldKeepNoCandidateMessageInCandidateWindow(self, cbTS): + return cbTS.imeDirName in ("chedayi", "checj", "cheliu") and not self.isCompositionCharPrefix(cbTS) + def getKeyState(self, keyCode): return ctypes.WinDLL("User32.dll").GetKeyState(keyCode) @@ -3070,12 +3284,43 @@ def initCinBaseContext(self, cbTS): cbTS.initCinBaseState = True + def customizeCandidateUI(self, cbTS, force=False): + cfg = cbTS.cfg # 所有 TextService 共享一份設定物件 + modernStyle = getattr(cfg, 'candidateModernStyle', False) + ui_args = { + "candFontSize": cfg.fontSize, + "candFontName": 'Microsoft JhengHei', + "candPerRow": cbTS.candPerRow, + "candUseCursor": cfg.cursorCandList, + "candidateModernStyle": modernStyle, + "candidateLayout": getattr(cfg, 'candidateLayout', 'horizontal'), + "candidatePerRow": getattr(cfg, 'candidatePerRow', 6), + "candidateEdgeAvoidance": getattr(cfg, 'candidateEdgeAvoidance', True), + "candidateTheme": getattr(cfg, 'candidateTheme', 'light'), + "candidateKeyStyle": getattr(cfg, 'candidateKeyStyle', 'keycap'), + "candidateColors": candidateColorsForTheme(cfg), + "candidateStyle": getattr(cfg, 'candidateStyle', {}), + "candidateStableWidth": getattr(cfg, 'candidateStableWidth', False), + "candidateMinWidth": getattr(cfg, 'candidateMinWidth', 0), + "candidateWrapToMaxWidth": getattr(cfg, 'candidateWrapToMaxWidth', True), + "candidateMaxWidth": getattr(cfg, 'candidateMaxWidth', 300), + } + if not force and getattr(cbTS, '_lastCandidateUIArgs', None) == ui_args: + return + cbTS._lastCandidateUIArgs = copy.deepcopy(ui_args) + cbTS.customizeUI(**ui_args) + def applyConfig(self, cbTS): cfg = cbTS.cfg # 所有 TextService 共享一份設定物件 cbTS.configVersion = cfg.getVersion() # 每列顯示幾個候選字 cbTS.candPerRow = cfg.candPerRow + if getattr(cfg, 'candidateModernStyle', False): + if getattr(cfg, 'candidateLayout', 'horizontal') == 'vertical': + cbTS.candPerRow = 1 + else: + cbTS.candPerRow = getattr(cfg, 'candidatePerRow', 6) # 如果程式為 UiLess 模式就取代設定 if cbTS.client.isUiLess: @@ -3083,12 +3328,11 @@ def applyConfig(self, cbTS): # 每頁顯示幾個候選字 cbTS.candPerPage = cfg.candPerPage + if getattr(cfg, 'candidateModernStyle', False) and getattr(cfg, 'candidateLayout', 'horizontal') == 'horizontal': + cbTS.candPerPage = cbTS.candPerRow # 設定 UI 外觀 - cbTS.customizeUI(candFontSize = cfg.fontSize, - candFontName = 'MingLiu', - candPerRow = cfg.candPerRow, - candUseCursor = cfg.cursorCandList) + self.customizeCandidateUI(cbTS, force=True) # 設定選字按鍵 (123456..., asdf.... 等) # if cbTS.cin.getSelection(): @@ -3127,6 +3371,11 @@ def applyConfig(self, cbTS): # 優先以聯想字詞排序候選清單? cbTS.sortByPhrase = cfg.sortByPhrase + # 隱藏組字串 (打字根時不在欄位顯示字根,選字後才 commit)? + cbTS.hideComposition = getattr(cfg, 'hideComposition', False) + cbTS.hideCompositionLabel = getattr(cfg, 'hideCompositionLabel', '') + cbTS.imeDisplayName = getattr(cfg, 'imeDisplayName', '') + # 拆錯字碼時自動清除輸入字串? cbTS.autoClearCompositionChar = cfg.autoClearCompositionChar @@ -3136,6 +3385,9 @@ def applyConfig(self, cbTS): # 直接顯示候選字清單 (不須按空白鍵)? cbTS.directShowCand = cfg.directShowCand + # 只有一個候選字時自動送出? + cbTS.autoCommitSingleCandidate = getattr(cfg, 'autoCommitSingleCandidate', False) + # 標點符號自動確認輸入? cbTS.directCommitSymbol = cfg.directCommitSymbol @@ -3278,18 +3530,22 @@ def __init__(self, cbTS, PhraseData): def run(self): self.PhraseData.loading = True - cfg = self.cbTS.cfg - datadirs = (cfg.getConfigDir(), cfg.getDataDir()) + try: + cfg = self.cbTS.cfg + datadirs = (cfg.getConfigDir(), cfg.getDataDir()) - if hasattr(self.PhraseData.phrase, '__del__'): - self.PhraseData.phrase.__del__() + if hasattr(self.PhraseData.phrase, '__del__'): + self.PhraseData.phrase.__del__() - self.PhraseData.phrase = None + self.PhraseData.phrase = None - phrasePath = cfg.findFile(datadirs, "phrase.json") - with io.open(phrasePath, 'r', encoding='utf8') as fs: - self.PhraseData.phrase = phrase(fs) - self.PhraseData.loading = False + phrasePath = cfg.findFile(datadirs, "phrase.json") + with io.open(phrasePath, 'r', encoding='utf8') as fs: + self.PhraseData.phrase = phrase(fs) + except Exception: + pass + finally: + self.PhraseData.loading = False class LoadCinTable(threading.Thread): @@ -3303,42 +3559,47 @@ def run(self): self.cbTS.debug.setStartTimer("LoadCinTable") self.CinTable.loading = True - if self.cbTS.cfg.selCinType >= len(self.cbTS.cinFileList): - self.cbTS.cfg.selCinType = 0 - selCinFile = self.cbTS.cinFileList[self.cbTS.cfg.selCinType] - jsonPath = os.path.join(self.cbTS.jsondir, selCinFile) - - if self.cbTS.reLoadCinTable or not hasattr(self.cbTS, 'cin'): - self.cbTS.reLoadCinTable = False - - if hasattr(self.cbTS, 'cin'): - self.cbTS.cin.__del__() - if hasattr(self.CinTable.cin, '__del__'): - self.CinTable.cin.__del__() - - self.cbTS.cin = None - self.CinTable.cin = None - - with io.open(jsonPath, 'r', encoding='utf8') as fs: - self.cbTS.cin = Cin(fs, self.cbTS.imeDirName, self.cbTS.ignorePrivateUseArea) - self.CinTable.cin = self.cbTS.cin - self.CinTable.curCinType = self.cbTS.cfg.selCinType - - if not hasattr(self.cbTS, 'extendtable'): - if self.cbTS.cfg.userExtendTable: - datadirs = (self.cbTS.cfg.getConfigDir(), self.cbTS.cfg.getDataDir()) - extendtablePath = self.cbTS.cfg.findFile(datadirs, "extendtable.dat") - with io.open(extendtablePath, encoding='utf-8') as fs: - self.cbTS.extendtable = extendtable(fs) - else: - self.cbTS.extendtable = {} - self.cbTS.cin.updateCinTable(self.cbTS.cfg.userExtendTable, self.cbTS.cfg.priorityExtendTable, self.cbTS.extendtable, self.cbTS.cfg.ignorePrivateUseArea) - self.CinTable.userExtendTable = self.cbTS.cfg.userExtendTable - self.CinTable.priorityExtendTable = self.cbTS.cfg.priorityExtendTable - self.CinTable.ignorePrivateUseArea = self.cbTS.cfg.ignorePrivateUseArea - self.CinTable.loading = False - - if DEBUG_MODE: + selCinFile = None + try: + if self.cbTS.cfg.selCinType >= len(self.cbTS.cinFileList): + self.cbTS.cfg.selCinType = 0 + selCinFile = self.cbTS.cinFileList[self.cbTS.cfg.selCinType] + jsonPath = os.path.join(self.cbTS.jsondir, selCinFile) + + if self.cbTS.reLoadCinTable or not hasattr(self.cbTS, 'cin'): + self.cbTS.reLoadCinTable = False + + if hasattr(self.cbTS, 'cin'): + self.cbTS.cin.__del__() + if hasattr(self.CinTable.cin, '__del__'): + self.CinTable.cin.__del__() + + self.cbTS.cin = None + self.CinTable.cin = None + + with io.open(jsonPath, 'r', encoding='utf8') as fs: + self.cbTS.cin = Cin(fs, self.cbTS.imeDirName, self.cbTS.ignorePrivateUseArea) + self.CinTable.cin = self.cbTS.cin + self.CinTable.curCinType = self.cbTS.cfg.selCinType + + if not hasattr(self.cbTS, 'extendtable'): + if self.cbTS.cfg.userExtendTable: + datadirs = (self.cbTS.cfg.getConfigDir(), self.cbTS.cfg.getDataDir()) + extendtablePath = self.cbTS.cfg.findFile(datadirs, "extendtable.dat") + with io.open(extendtablePath, encoding='utf-8') as fs: + self.cbTS.extendtable = extendtable(fs) + else: + self.cbTS.extendtable = {} + self.cbTS.cin.updateCinTable(self.cbTS.cfg.userExtendTable, self.cbTS.cfg.priorityExtendTable, self.cbTS.extendtable, self.cbTS.cfg.ignorePrivateUseArea) + self.CinTable.userExtendTable = self.cbTS.cfg.userExtendTable + self.CinTable.priorityExtendTable = self.cbTS.cfg.priorityExtendTable + self.CinTable.ignorePrivateUseArea = self.cbTS.cfg.ignorePrivateUseArea + except Exception: + pass + finally: + self.CinTable.loading = False + + if DEBUG_MODE and selCinFile: self.cbTS.debug.setEndTimer("LoadCinTable") self.cbTS.debugLog[time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + " [C]"] = self.cbTS.debug.info['brand'] + ":「" + self.cbTS.debug.jsonNameDict[selCinFile] + "」碼表載入時間約為 " + self.cbTS.debug.getDurationTime("LoadCinTable") + " 秒" @@ -3364,25 +3625,30 @@ def run(self): self.cbTS.debug.setStartTimer("LoadRCinTable") self.RCinTable.loading = True - selCinFile = self.rcinFileList[self.cbTS.cfg.selRCinType] - jsonPath = os.path.join(self.cbTS.jsondir, selCinFile) + selCinFile = None + try: + selCinFile = self.rcinFileList[self.cbTS.cfg.selRCinType] + jsonPath = os.path.join(self.cbTS.jsondir, selCinFile) - if self.RCinTable.cin is not None and hasattr(self.RCinTable.cin, '__del__'): - self.RCinTable.cin.__del__() + if self.RCinTable.cin is not None and hasattr(self.RCinTable.cin, '__del__'): + self.RCinTable.cin.__del__() - self.RCinTable.cin = None + self.RCinTable.cin = None - if os.path.exists(jsonPath): - self.cbTS.RCinFileNotExist = False - with io.open(jsonPath, 'r', encoding='utf8') as fs: - self.RCinTable.cin = RCin(fs, self.cbTS.imeDirName) - else: - self.cbTS.RCinFileNotExist = True - - self.RCinTable.curCinType = self.cbTS.cfg.selRCinType - self.RCinTable.loading = False + if os.path.exists(jsonPath): + self.cbTS.RCinFileNotExist = False + with io.open(jsonPath, 'r', encoding='utf8') as fs: + self.RCinTable.cin = RCin(fs, self.cbTS.imeDirName) + else: + self.cbTS.RCinFileNotExist = True - if DEBUG_MODE: + self.RCinTable.curCinType = self.cbTS.cfg.selRCinType + except Exception: + pass + finally: + self.RCinTable.loading = False + + if DEBUG_MODE and selCinFile: self.cbTS.debug.setEndTimer("LoadRCinTable") self.cbTS.debugLog[time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + " [R]"] = self.cbTS.debug.info['brand'] + ":「" + self.cbTS.debug.jsonNameDict[selCinFile] + "」反查碼表載入時間約為 " + self.cbTS.debug.getDurationTime("LoadRCinTable") + " 秒" @@ -3398,19 +3664,24 @@ def run(self): self.cbTS.debug.setStartTimer("LoadHCinTable") self.HCinTable.loading = True - selCinFile = CinBase.hcinFileList[self.cbTS.cfg.selHCinType] - jsonPath = os.path.join(self.cbTS.jsondir, selCinFile) - - if self.HCinTable.cin is not None and hasattr(self.HCinTable.cin, '__del__'): - self.HCinTable.cin.__del__() + selCinFile = None + try: + selCinFile = CinBase.hcinFileList[self.cbTS.cfg.selHCinType] + jsonPath = os.path.join(self.cbTS.jsondir, selCinFile) - self.HCinTable.cin = None + if self.HCinTable.cin is not None and hasattr(self.HCinTable.cin, '__del__'): + self.HCinTable.cin.__del__() - with io.open(jsonPath, 'r', encoding='utf8') as fs: - self.HCinTable.cin = HCin(fs, self.cbTS.imeDirName) - self.HCinTable.curCinType = self.cbTS.cfg.selHCinType - self.HCinTable.loading = False + self.HCinTable.cin = None - if DEBUG_MODE: + with io.open(jsonPath, 'r', encoding='utf8') as fs: + self.HCinTable.cin = HCin(fs, self.cbTS.imeDirName) + self.HCinTable.curCinType = self.cbTS.cfg.selHCinType + except Exception: + pass + finally: + self.HCinTable.loading = False + + if DEBUG_MODE and selCinFile: self.cbTS.debug.setEndTimer("LoadHCinTable") self.cbTS.debugLog[time.strftime("%Y-%m-%d %H:%M:%S", time.localtime()) + " [H]"] = self.cbTS.debug.info['brand'] + ":「" + self.cbTS.debug.jsonNameDict[selCinFile] + "」同音字碼表載入時間約為 " + self.cbTS.debug.getDurationTime("LoadHCinTable") + " 秒" diff --git a/python/cinbase/cin.py b/python/cinbase/cin.py index 074924aaf..180ed08e1 100644 --- a/python/cinbase/cin.py +++ b/python/cinbase/cin.py @@ -123,6 +123,16 @@ def haveNextCharDef(self, key): return chardefslist + def hasLongerCharDefPrefix(self, key): + if not key: + return False + key_length = len(key) + for chardef in self.chardefs: + if len(chardef) > key_length and chardef.startswith(key): + return True + return False + + def getWildcardCharDefs(self, CompositionChar, WildcardChar, candMaxItems): wildcardchardefs = [] matchchardefs = {} diff --git a/python/cinbase/config.py b/python/cinbase/config.py index 3b3bc0ac0..e5669f6bf 100644 --- a/python/cinbase/config.py +++ b/python/cinbase/config.py @@ -49,6 +49,7 @@ def __init__(self): self.autoClearCompositionChar = False self.playSoundWhenNonCand = False self.directShowCand = False + self.autoCommitSingleCandidate = False self.directCommitSymbol = False self.fontSize = DEF_FONT_SIZE self.selCinType = 0 @@ -77,6 +78,27 @@ def __init__(self): self.messageDurationTime = 3 self.keyboardType = 0 self.selDayiSymbolCharType = 0 + self.hideComposition = False + self.hideCompositionLabel = "" + self.imeDisplayName = "" + self.candidateModernStyle = False + self.candidateLayout = "horizontal" + self.candidatePerRow = 6 + self.candidateEdgeAvoidance = True + self.candidateTheme = "Night Comfort" + self.candidateKeyStyle = "keycap" + self.candidateMessageStyle = "badge" + self.candidateMessageBehavior = "progressive" + self.candidateStableWidth = False + self.candidateMinWidth = 0 + self.candidateWrapToMaxWidth = True + self.candidateMaxWidth = 300 + self.candidateColors = {} + self.candidateStyle = { + "contentMargin": 6, + "textMargin": 4, + "borderRadius": 6, + } self.ignoreSaveList = ["ignoreSaveList", "curdir", "cinFileList", "selCinFile", "imeDirName", "_version", "_lastUpdateTime"] self.curdir = os.path.abspath(os.path.dirname(__file__)) @@ -103,21 +125,33 @@ def getLastTime(self): return self._lastTime def load(self): + # Layer 1: apply shipped defaults so new keys reach all users regardless of + # whether they already have an APPDATA config from a previous install. + default_config = os.path.join(self.getDefaultConfigDir(), "config.json") + try: + if os.path.exists(default_config) and os.stat(default_config).st_size > 0: + with open(default_config, "r") as f: + self.__dict__.update(json.load(f)) + except Exception: + pass + + # Layer 2: overlay with the user's personal config (APPDATA or legacy home-dir path). filename = self.getConfigFile() try: if not os.path.exists(filename) or os.stat(filename).st_size == 0: filename = os.path.join(os.path.expanduser("~"), "PIME", self.imeDirName, "config.json") if not os.path.exists(filename) or os.stat(filename).st_size == 0: - filename = os.path.join(self.getDefaultConfigDir(), "config.json") + filename = None else: src_dir = os.path.join(os.path.expanduser("~"), "PIME", self.imeDirName) dst_dir = self.getConfigDir() self.copytree(src_dir, dst_dir) filename = self.getConfigFile() - with open(filename, "r") as f: - self.__dict__.update(json.load(f)) + if filename: + with open(filename, "r") as f: + self.__dict__.update(json.load(f)) except Exception: self.save() self.update() @@ -129,8 +163,7 @@ def save(self): filename = self.getConfigFile() try: with open(filename, "w") as f: - jsondata = {key: value for key, value in self.__dict__.items() if not key in self.ignoreSaveList} - js = json.dump(jsondata, f, sort_keys=True, indent=4) + json.dump(self.toJson(), f, sort_keys=True, indent=4) self.update() except Exception: pass # FIXME: handle I/O errors? diff --git a/python/cinbase/config/config.htm b/python/cinbase/config/config.htm index 4d0684325..05f22a30f 100644 --- a/python/cinbase/config/config.htm +++ b/python/cinbase/config/config.htm @@ -134,6 +134,9 @@

候選清單


+ + +
@@ -254,37 +257,72 @@

WIP...

-

介面設定

-
-
-
- -
-
-
- -
-
-
- -
-
-
- -
-
-
-
-
- -
-
-
-
-

介面預覽

+

候選窗外觀

-
-
+
+
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ 候選窗配色 + +
+
+
+ 選字符樣式 + +
+
+ +
+ 提示訊息樣式 + +
+
+ +
+ 提示強度行為 + +
+
diff --git a/python/cinbase/config/css/config.css b/python/cinbase/config/css/config.css index 31644d645..cfa6d717b 100644 --- a/python/cinbase/config/css/config.css +++ b/python/cinbase/config/css/config.css @@ -21,6 +21,519 @@ color: blue; } +.candidate-window-settings { + background: #fbfcfe; + padding: 18px 20px 22px; +} + +.candidate-appearance-shell { + max-width: 1180px; +} + +.candidate-theme-select { + display: none; +} + +.candidate-toolbar { + background: #ffffff; + border: 1px solid #d9dee8; + border-radius: 6px; + display: flex; + flex-direction: column; + gap: 16px; + margin-bottom: 18px; + padding: 14px 16px; +} + +.candidate-option-row, +.candidate-number-row { + display: grid; + gap: 14px 18px; +} + +.candidate-option-row { + grid-template-columns: repeat(4, minmax(180px, 1fr)); +} + +.candidate-number-row { + align-items: end; + grid-template-columns: repeat(4, minmax(150px, 1fr)); +} + +.candidate-config-field { + min-height: 38px; +} + +.candidate-config-field>label { + color: #273142; + display: block; + font-weight: 600; + margin: 0 0 6px; +} + +.candidate-config-field .input-group { + max-width: 172px; +} + +.candidate-config-field input[type="text"] { + height: 32px; +} + +.candidate-config-field select { + height: 32px; + max-width: 220px; +} + +.candidate-config-check { + align-items: center; + display: flex; + gap: 8px; + min-height: 40px; +} + +.candidate-config-check input[type="checkbox"] { + margin: 0; +} + +.candidate-config-check label { + color: #273142; + font-weight: 600; + margin: 0; +} + +.candidate-theme-heading { + align-items: baseline; + color: #202938; + display: flex; + font-weight: 700; + justify-content: space-between; + margin: 4px 0 10px; +} + +.candidate-theme-heading-spaced { + margin-top: 18px; +} + +.candidate-theme-current { + color: #5f6d7e; + font-size: 12px; + font-weight: 600; +} + +.candidate-theme-grid, +.candidate-style-grid { + display: grid; + gap: 12px; + grid-template-columns: repeat(auto-fill, minmax(265px, 1fr)); +} + +.candidate-theme-card, +.candidate-style-card { + background: #ffffff; + border: 1px solid #d8dee9; + border-radius: 6px; + box-shadow: 0 8px 18px rgba(15, 23, 42, 0.06); + color: inherit; + cursor: pointer; + padding: 10px; + text-align: left; + transition: border-color 120ms ease, box-shadow 120ms ease, transform 120ms ease; +} + +.candidate-theme-card:hover, +.candidate-style-card:hover { + border-color: #9aa8bd; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.1); +} + +.candidate-theme-card.selected, +.candidate-style-card.selected { + border-color: #3f6fe8; + box-shadow: 0 0 0 2px rgba(63, 111, 232, 0.18), 0 12px 28px rgba(15, 23, 42, 0.1); +} + +.candidate-theme-card-header, +.candidate-style-card-header { + align-items: center; + display: flex; + font-size: 12px; + font-weight: 700; + justify-content: space-between; + margin-bottom: 8px; +} + +.candidate-theme-card-name, +.candidate-style-card-name { + color: #28313d; +} + +.candidate-theme-card-state, +.candidate-style-card-state { + color: #2d64d8; + font-size: 11px; + min-width: 26px; + text-align: right; +} + +.candidate-preview { + background: #1b1c20; + border: 1px solid #4a4d57; + border-radius: 6px; + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.12); + box-sizing: border-box; + color: #e5e8ee; + display: block; + font-family: "Microsoft JhengHei", "MingLiu", sans-serif; + font-size: 14px; + min-width: 0; + overflow: hidden; + width: 100%; +} + +.candidate-preview-header { + align-items: center; + border-bottom: 1px solid #30323a; + display: flex; + gap: 12px; + min-height: 28px; + padding: 4px 9px; +} + +.candidate-preview-name, +.candidate-preview-page { + color: #a9afba; +} + +.candidate-preview-root { + color: #b8c7e8; + font-weight: 600; +} + +.candidate-preview-page { + margin-left: auto; +} + +.candidate-preview-body { + display: flex; + flex-wrap: nowrap; + gap: 8px; + min-height: 36px; + padding: 6px; +} + +.candidate-preview.wrap .candidate-preview-body { + flex-wrap: wrap; +} + +.candidate-preview-message-body { + display: block; +} + +.candidate-preview-message-row { + align-items: center; + color: var(--candidate-message-text, #ffe1a8); + display: flex; + gap: 8px; + min-height: 24px; + padding: 2px 7px; + white-space: nowrap; +} + +.candidate-preview.message-style-badge .candidate-preview-message-row { + background: var(--candidate-message-bg, rgba(191, 134, 67, 0.16)); + border-radius: 5px; +} + +.candidate-preview-message-badge { + align-items: center; + background: var(--candidate-message-accent, #bf8643); + border-radius: 5px; + color: var(--candidate-message-badge-text, #fff); + display: inline-flex; + font-size: 0.82em; + font-weight: 700; + height: 18px; + justify-content: center; + min-width: 22px; +} + +.candidate-preview.message-style-bar .candidate-preview-message-row { + border-left: 3px solid var(--candidate-message-accent, #d8aa62); + border-radius: 2px; + padding-left: 10px; +} + +.candidate-preview-message-dot { + background: var(--candidate-message-accent, #d7a65f); + border-radius: 999px; + display: inline-block; + height: 7px; + width: 7px; +} + +.candidate-preview-message-text { + color: var(--candidate-message-text, #ffe1a8); + font-weight: 650; +} + +.candidate-behavior-preview { + display: grid; + gap: 6px; +} + +.candidate-behavior-preview .candidate-preview { + box-shadow: none; +} + +.candidate-behavior-label { + color: #6b7483; + font-size: 11px; + font-weight: 700; +} + +.candidate-preview-item { + align-items: center; + display: inline-flex; + gap: 4px; + justify-content: center; + min-height: 24px; + min-width: 38px; + padding: 2px 7px; + white-space: nowrap; +} + +.candidate-preview-item span { + color: #aeb9cf; +} + +.candidate-preview-key { + min-width: 0.85em; + text-align: center; +} + +.candidate-preview-word { + color: inherit; +} + +.candidate-preview.key-style-keycap .candidate-preview-key { + color: inherit; + font-size: 0.86em; + opacity: 0.58; +} + +.candidate-preview.key-style-keycap .candidate-preview-item.active .candidate-preview-key { + align-items: center; + background: rgba(255, 255, 255, 0.06); + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 4px; + display: inline-flex; + font-size: 0.86em; + height: 18px; + justify-content: center; + line-height: 1; + min-width: 17px; + padding: 0 3px; +} + +.candidate-preview.key-style-divider .candidate-preview-key { + border-right: 1px solid rgba(169, 175, 186, 0.35); + display: inline-block; + margin-right: 1px; + min-width: 17px; + padding-right: 4px; + text-align: center; +} + +.candidate-preview.key-style-divider .candidate-preview-item.active .candidate-preview-key { + border-right-color: rgba(255, 255, 255, 0.3); +} + +.candidate-preview.key-style-quiet .candidate-preview-key { + font-size: 0.86em; + opacity: 0.58; +} + +.candidate-preview.key-style-quiet .candidate-preview-word { + font-weight: 650; +} + +.candidate-preview.key-style-underline .candidate-preview-key { + border-bottom: 1px solid currentColor; + line-height: 1.05; + padding-bottom: 2px; +} + +.candidate-preview.key-style-badge .candidate-preview-key, +.candidate-preview.key-style-soft-capsule .candidate-preview-key { + align-items: center; + border-radius: 999px; + display: inline-flex; + font-size: 0.86em; + height: 18px; + justify-content: center; + line-height: 1; + min-width: 17px; + padding: 0 4px; +} + +.candidate-preview.key-style-badge .candidate-preview-key { + background: transparent; + border: 1px solid rgba(169, 175, 186, 0.38); +} + +.candidate-preview.key-style-soft-capsule .candidate-preview-key { + background: rgba(169, 175, 186, 0.12); + border: 1px solid transparent; +} + +.candidate-preview.key-style-accent-dot .candidate-preview-key { + min-width: 18px; + padding-left: 8px; + position: relative; +} + +.candidate-preview.key-style-accent-dot .candidate-preview-key::before { + background: currentColor; + border-radius: 999px; + content: ""; + height: 3px; + left: 1px; + opacity: 0.75; + position: absolute; + top: 50%; + transform: translateY(-50%); + width: 3px; +} + +.candidate-preview.key-style-accent-dot .candidate-preview-item.active .candidate-preview-key::before { + height: 11px; + opacity: 0.95; + width: 4px; +} + +.candidate-preview.key-style-rail .candidate-preview-item { + border-left: 2px solid rgba(169, 175, 186, 0.25); + border-radius: 3px 6px 6px 3px; + padding-left: 6px; +} + +.candidate-preview.key-style-rail .candidate-preview-item.active { + border-left-color: currentColor; +} + +.candidate-preview.key-style-monospace-slot .candidate-preview-key { + font-family: ui-monospace, "Cascadia Mono", Consolas, monospace; + min-width: 16px; +} + +.candidate-preview.key-style-word-first .candidate-preview-word { + order: 1; +} + +.candidate-preview.key-style-word-first .candidate-preview-key { + align-self: flex-start; + font-size: 0.72em; + min-width: 0; + opacity: 0.7; + order: 2; + transform: translateY(2px); +} + +.candidate-preview.key-style-left-tag .candidate-preview-item { + border: 1px solid rgba(169, 175, 186, 0.18); + gap: 0; + overflow: hidden; + padding: 0 6px 0 0; +} + +.candidate-preview.key-style-left-tag .candidate-preview-key { + align-items: center; + align-self: stretch; + background: rgba(169, 175, 186, 0.12); + display: inline-flex; + justify-content: center; + margin-right: 5px; + min-width: 19px; +} + +.candidate-preview.key-style-glow-key .candidate-preview-item.active .candidate-preview-key { + text-shadow: 0 0 8px currentColor; +} + +.candidate-preview.key-style-micro-tab .candidate-preview-item { + align-items: flex-end; + padding-top: 3px; +} + +.candidate-preview.key-style-micro-tab .candidate-preview-key { + align-items: center; + align-self: flex-start; + background: rgba(169, 175, 186, 0.12); + border-radius: 3px; + display: inline-flex; + font-size: 0.72em; + height: 11px; + justify-content: center; + min-width: 14px; + padding: 0 3px; + transform: translateY(-2px); +} + +.candidate-preview.key-style-word-anchor .candidate-preview-key { + font-size: 0.82em; + opacity: 0.62; +} + +.candidate-preview.key-style-word-anchor .candidate-preview-word { + font-weight: 620; + position: relative; +} + +.candidate-preview.key-style-word-anchor .candidate-preview-word::after { + background: currentColor; + bottom: -3px; + content: ""; + height: 1px; + left: 0; + opacity: 0.38; + position: absolute; + right: 0; +} + +.candidate-preview-item.active { + background: #263342; + border: 1px solid #263342; + border-radius: 6px; + color: #eef4ff; +} + +.candidate-preview-item.active span { + color: #eef4ff; +} + +@media (max-width: 720px) { + .candidate-window-settings { + padding: 14px; + } + + .candidate-toolbar { + gap: 12px; + } + + .candidate-option-row, + .candidate-number-row { + grid-template-columns: 1fr; + } + + .candidate-theme-grid { + grid-template-columns: 1fr; + } + + .candidate-style-grid { + grid-template-columns: 1fr; + } +} + .subOption { list-style: none; margin-left: -24px; diff --git a/python/cinbase/config/js/config.js b/python/cinbase/config/js/config.js index c7adefb63..c865c4fd4 100644 --- a/python/cinbase/config/js/config.js +++ b/python/cinbase/config/js/config.js @@ -64,6 +64,7 @@ var selHCins = [ var debugMode = false; var checjConfig = {}; var cinCount = {}; +var configLoaded = false; var CONFIG_URL = '/config'; var VERSION_URL = '/version.txt'; var KEEP_ALIVE_URL = '/keep_alive'; @@ -129,6 +130,7 @@ if (!Date.now) { function loadConfig() { $.get(CONFIG_URL, function(data, status) { checjConfig = data.config; + applyCandidateDefaults(); cinCount = data.cincount; symbolsData = data.symbols; swkbData = data.swkb; @@ -136,10 +138,582 @@ function loadConfig() { phraseData = data.phrase; flangsData = data.flangs; extendtableData = data.extendtable; + configLoaded = true; }, "json"); } loadConfig(); +function applyCandidateDefaults() { + var currentIme = typeof imeFolderName !== "undefined" ? imeFolderName : ""; + if (typeof checjConfig.autoCommitSingleCandidate === "undefined") { + checjConfig.autoCommitSingleCandidate = false; + } + if (typeof checjConfig.candidateKeyStyle === "undefined") { + checjConfig.candidateKeyStyle = "keycap"; + } + else if (checjConfig.candidateKeyStyle == "underline" || checjConfig.candidateKeyStyle == "rule" || checjConfig.candidateKeyStyle == "rule-key") { + checjConfig.candidateKeyStyle = "word-anchor"; + } + if (typeof checjConfig.candidateMessageStyle === "undefined") { + checjConfig.candidateMessageStyle = "badge"; + } + if (typeof checjConfig.candidateMessageBehavior === "undefined") { + checjConfig.candidateMessageBehavior = "progressive"; + } + var validCandidateKeyStyles = { + keycap: true, + quiet: true, + divider: true, + badge: true, + "accent-dot": true, + rail: true, + "monospace-slot": true, + "word-first": true, + "soft-capsule": true, + "left-tag": true, + "glow-key": true, + "micro-tab": true, + "word-anchor": true + }; + if (!validCandidateKeyStyles[checjConfig.candidateKeyStyle]) { + checjConfig.candidateKeyStyle = "keycap"; + } + var validCandidateMessageStyles = { + badge: true, + bar: true, + dot: true + }; + if (!validCandidateMessageStyles[checjConfig.candidateMessageStyle]) { + checjConfig.candidateMessageStyle = "badge"; + } + var validCandidateMessageBehaviors = { + fixed: true, + progressive: true + }; + if (!validCandidateMessageBehaviors[checjConfig.candidateMessageBehavior]) { + checjConfig.candidateMessageBehavior = "progressive"; + } + var modernDefaultIme = ["chedayi", "checj", "cheliu"].indexOf(currentIme) >= 0; + if (!modernDefaultIme) { + return; + } + if (typeof checjConfig.candidateModernStyle === "undefined") { + checjConfig.candidateModernStyle = true; + } + if (typeof checjConfig.candidateStableWidth === "undefined") { + checjConfig.candidateStableWidth = true; + } + if (typeof checjConfig.candidateMinWidth === "undefined" || checjConfig.candidateMinWidth < 160) { + checjConfig.candidateMinWidth = 286; + } + if (typeof checjConfig.candidateWrapToMaxWidth === "undefined") { + checjConfig.candidateWrapToMaxWidth = true; + } + if (typeof checjConfig.candidateMaxWidth === "undefined" || checjConfig.candidateMaxWidth < 220) { + checjConfig.candidateMaxWidth = 300; + } + if (typeof checjConfig.candidateTheme === "undefined") { + checjConfig.candidateTheme = "Night Comfort"; + } + if (typeof checjConfig.candidatePerRow === "undefined") { + checjConfig.candidatePerRow = 6; + } + if (typeof checjConfig.candidateEdgeAvoidance === "undefined") { + checjConfig.candidateEdgeAvoidance = true; + } +} + +var candidateThemeNames = [ + "Night Comfort", + "Soft Focus", + "Warm Gray", + "Graphite", + "Slate Teal", + "Olive", + "Plum", + "Amber", + "Light", + "Paper", + "Mist Light", + "Sepia Dim" +]; + +var candidateThemePalette = { + "Night Comfort": ["#1b1c20", "#4a4d57", "#30323a", "#e5e8ee", "#a9afba", "#b8c7e8", "#405f8a", "#5e7ea7", "#eef4ff", "#aeb9cf"], + "Soft Focus": ["#191d21", "#44525a", "#2b343a", "#e4ebee", "#a8b5ba", "#9cc8bd", "#3f6f6b", "#6a9993", "#ecfbf8", "#a4bcb6"], + "Warm Gray": ["#20201d", "#58554b", "#39372f", "#ebe7dc", "#b7b1a3", "#d7c48e", "#5f684d", "#87936f", "#f7f3e7", "#c1b8a2"], + "Graphite": ["#12141a", "#444a57", "#292e38", "#f3f5fa", "#aeb5c4", "#8fb3ff", "#4169d7", "#6f92eb", "#edf3ff", "#9eb0d5"], + "Slate Teal": ["#152027", "#3f5a64", "#263943", "#f0f8fb", "#a5bac2", "#87d4dd", "#2f7f9f", "#60adc8", "#e9fbff", "#98c2ca"], + "Olive": ["#171b16", "#4b5941", "#2c3328", "#f4f7ef", "#b4bda7", "#b6df88", "#5d7f36", "#91b962", "#f4ffe8", "#b9c8a8"], + "Plum": ["#1d1721", "#604b66", "#382c3e", "#fbf4ff", "#c0adca", "#e0a7ff", "#7a55b8", "#aa83e6", "#fbf3ff", "#c5a9d1"], + "Amber": ["#211a12", "#68533a", "#3c2f22", "#fff8ed", "#cfbda4", "#ffc46f", "#9a6730", "#d59a58", "#fff3de", "#d4baa0"], + "Light": ["#f7f9fc", "#aeb8cb", "#dbe2ed", "#182235", "#657187", "#2f66dc", "#2f6eea", "#1d56c4", "#ffffff", "#44639a"], + "Paper": ["#fbfaf6", "#b7ac9c", "#e4ded4", "#272119", "#786b5d", "#8a4f17", "#315f87", "#244967", "#f7fbff", "#6f665c"], + "Mist Light": ["#e9edf0", "#a8b3bc", "#d4dce1", "#24303a", "#66727d", "#426b85", "#5f7f94", "#4b687b", "#f7fbfd", "#577385"], + "Sepia Dim": ["#28251f", "#5d564a", "#403a31", "#ebe2d3", "#b9ad9a", "#dfc58e", "#6d6547", "#958a63", "#f8efd9", "#c7b79e"] +}; + +var candidateKeyStyleOptions = { + keycap: "Selected Only Keycap", + quiet: "Quiet Key", + divider: "Divider Slim", + badge: "Badge Minimal", + "accent-dot": "Accent Dot", + rail: "Rail Marker", + "monospace-slot": "Monospace Slot", + "word-first": "Word First", + "soft-capsule": "Soft Capsule", + "left-tag": "Left Tag", + "glow-key": "Glow Key", + "micro-tab": "Micro Tab", + "word-anchor": "Word Anchor" +}; + +var candidateMessageStyleOptions = { + badge: "A Badge Alert", + bar: "B Bar Notice", + dot: "D Dot Signal" +}; + +var candidateMessageBehaviorOptions = { + fixed: "固定樣式", + progressive: "打字中低調,確認後明顯" +}; + +var candidateKeyStyleClassNames = [ + "key-style-keycap", + "key-style-quiet", + "key-style-divider", + "key-style-badge", + "key-style-accent-dot", + "key-style-rail", + "key-style-monospace-slot", + "key-style-word-first", + "key-style-soft-capsule", + "key-style-left-tag", + "key-style-glow-key", + "key-style-micro-tab", + "key-style-word-anchor" +]; + +function getCandidatePreviewSample() { + var previewName = checjConfig.imeDisplayName || "大易"; + var root = "月"; + var candidates = ["明", "朋", "服", "朗", "朝", "朔", "期", "望", "有", "肚"]; + var selKeys = "1234567890"; + + if (imeFolderName == "chedayi") { + previewName = checjConfig.imeDisplayName || "大易"; + root = "魚"; + candidates = ["刀", "川", "夕", "角", "魚", "互", "句", "象", "魯", "鮮"]; + selKeys = "␣'[]-\\"; + } + else if (imeFolderName == "checj") { + previewName = checjConfig.imeDisplayName || "酷倉"; + root = "一日"; + candidates = ["是", "題", "暫", "量", "更", "旦", "曹", "晉", "晝", "書"]; + } + else if (imeFolderName == "cheliu") { + previewName = checjConfig.imeDisplayName || "蝦米"; + root = "魚"; + candidates = ["魯", "鮮", "鯉", "鯨", "鱗", "鰭", "鯛", "鰻", "鯨", "鱸"]; + } + + return { + name: previewName, + root: root, + candidates: candidates, + selKeys: selKeys + }; +} + +function hexToRgb(color) { + var value = (color || "").replace("#", ""); + if (value.length !== 6) { + return null; + } + return { + r: parseInt(value.substr(0, 2), 16), + g: parseInt(value.substr(2, 2), 16), + b: parseInt(value.substr(4, 2), 16) + }; +} + +function blendHex(a, b, percentB) { + var rgbA = hexToRgb(a); + var rgbB = hexToRgb(b); + if (!rgbA || !rgbB) { + return b; + } + var percentA = 100 - percentB; + var toHex = function(value) { + var hex = Math.round(value).toString(16); + return hex.length == 1 ? "0" + hex : hex; + }; + return "#" + + toHex((rgbA.r * percentA + rgbB.r * percentB) / 100) + + toHex((rgbA.g * percentA + rgbB.g * percentB) / 100) + + toHex((rgbA.b * percentA + rgbB.b * percentB) / 100); +} + +function colorLuma(color) { + var rgb = hexToRgb(color); + if (!rgb) { + return 0; + } + return Math.round((rgb.r * 299 + rgb.g * 587 + rgb.b * 114) / 1000); +} + +function colorContrastHex(a, b) { + return Math.abs(colorLuma(a) - colorLuma(b)); +} + +function readableTextOnHex(bg, preferred, alternate) { + var dark = "#111827"; + var light = "#f8fafc"; + var result = preferred; + var resultContrast = colorContrastHex(bg, result); + var alternateContrast = colorContrastHex(bg, alternate); + if (alternateContrast > resultContrast) { + result = alternate; + resultContrast = alternateContrast; + } + var darkContrast = colorContrastHex(bg, dark); + var lightContrast = colorContrastHex(bg, light); + if (resultContrast < 72 && darkContrast > resultContrast) { + result = dark; + resultContrast = darkContrast; + } + if (resultContrast < 72 && lightContrast > resultContrast) { + result = light; + } + return result; +} + +function applyCandidatePreviewTheme(preview, theme, modern) { + var selectedBg = modern ? blendHex(theme[0], theme[6], 28) : "#000000"; + var selectedFg = modern ? readableTextOnHex(selectedBg, theme[8], theme[3]) : "#ffffff"; + var selectedBorder = modern + ? (colorContrastHex(selectedBg, theme[7]) >= 38 ? blendHex(theme[7], selectedBg, 28) : blendHex(selectedFg, selectedBg, 40)) + : "#000000"; + preview.css({ + "background-color": modern ? theme[0] : "#ffffff", + "border-color": modern ? theme[1] : "#000000", + "border-radius": modern ? "6px" : "0", + "color": modern ? theme[3] : "#000000" + }); + preview.find(".candidate-preview-header").css("border-bottom-color", modern ? theme[2] : "#d0d0d0"); + preview.find(".candidate-preview-name, .candidate-preview-page").css("color", modern ? theme[4] : "#0000b4"); + preview.find(".candidate-preview-root").css("color", modern ? theme[5] : "#0000b4"); + preview.find(".candidate-preview-key").css("color", modern ? theme[9] : "#0000ff"); + preview.find(".candidate-preview-word").css("color", modern ? theme[3] : "#000000"); + preview.find(".candidate-preview-item.active").css({ + "background-color": selectedBg, + "border-color": selectedBorder, + "border-radius": modern ? "6px" : "0", + "color": selectedFg + }); + preview.find(".candidate-preview-item.active .candidate-preview-key, .candidate-preview-item.active .candidate-preview-word").css("color", selectedFg); +} + +function applyCandidatePreviewKeyStyle(preview, keyStyle) { + keyStyle = keyStyle || $("#candidateKeyStyle").val() || checjConfig.candidateKeyStyle || "keycap"; + preview + .removeClass(candidateKeyStyleClassNames.join(" ")) + .addClass("key-style-" + keyStyle); +} + +function fillCandidatePreviewItems(preview, sample) { + var count = parseInt($("#candidatePerRow").val(), 10) || 4; + count = Math.max(1, Math.min(count, 10)); + var selKeys = sample.selKeys || "1234567890"; + var body = preview.find(".candidate-preview-body"); + body.empty(); + + for (var i = 0; i < count; ++i) { + var item = $("").addClass("candidate-preview-item"); + if (i == 0) { + item.addClass("active"); + } + item.append($("").addClass("candidate-preview-key").text(selKeys.charAt(i % selKeys.length))); + item.append($("").addClass("candidate-preview-word").text(sample.candidates[i % sample.candidates.length])); + body.append(item); + } +} + +function candidatePreviewFontSize() { + var fontSize = parseInt($("#fontSize").val(), 10) || 12; + return Math.max(6, Math.min(fontSize, 48)); +} + +function createCandidatePreview(sample, keyStyle) { + var preview = $("
").addClass("candidate-preview"); + var header = $("
").addClass("candidate-preview-header"); + header.append($("").addClass("candidate-preview-name").text(sample.name)); + header.append($("").addClass("candidate-preview-root").text(sample.root)); + header.append($("").addClass("candidate-preview-page").text("1/1")); + preview.append(header); + preview.append($("
").addClass("candidate-preview-body")); + fillCandidatePreviewItems(preview, sample); + applyCandidatePreviewKeyStyle(preview, keyStyle); + return preview; +} + +function applyCandidatePreviewMessageTheme(preview, theme, modern) { + var accent = modern ? theme[7] : "#bf8643"; + var messageBg = modern + ? (colorLuma(theme[0]) > 165 ? blendHex(theme[0], accent, 8) : blendHex(theme[0], accent, 13)) + : "#fff3dd"; + var messageText = modern + ? (colorLuma(theme[0]) > 165 ? "#7a430d" : blendHex(theme[3], accent, 38)) + : "#7a430d"; + var badgeText = colorLuma(accent) > 150 ? "#1b1c20" : "#ffffff"; + preview.css({ + "--candidate-message-accent": accent, + "--candidate-message-bg": messageBg, + "--candidate-message-text": messageText, + "--candidate-message-badge-text": badgeText + }); +} + +function createCandidateMessagePreview(sample, messageStyle) { + var preview = $("
").addClass("candidate-preview candidate-message-preview message-style-" + messageStyle); + var header = $("
").addClass("candidate-preview-header"); + header.append($("").addClass("candidate-preview-name").text(sample.name)); + header.append($("").addClass("candidate-preview-root").text(sample.root + sample.root + sample.root)); + preview.append(header); + + var body = $("
").addClass("candidate-preview-body candidate-preview-message-body"); + var row = $("
").addClass("candidate-preview-message-row"); + if (messageStyle == "badge") { + row.append($("").addClass("candidate-preview-message-badge").text("!")); + } + else if (messageStyle == "dot") { + row.append($("").addClass("candidate-preview-message-dot")); + } + row.append($("").addClass("candidate-preview-message-text").text("查無組字")); + body.append(row); + preview.append(body); + return preview; +} + +function createCandidateMessageBehaviorPreview(sample, behavior, selectedStyle) { + var wrap = $("
").addClass("candidate-behavior-preview"); + var typingStyle = behavior == "progressive" ? "dot" : selectedStyle; + var confirmedStyle = selectedStyle; + + wrap.append($("
").addClass("candidate-behavior-label").text(behavior == "progressive" ? "打字中:低調" : "打字中:固定樣式")); + wrap.append(createCandidateMessagePreview(sample, typingStyle)); + wrap.append($("
").addClass("candidate-behavior-label").text(behavior == "progressive" ? "確認後:選用樣式" : "確認後:固定樣式")); + wrap.append(createCandidateMessagePreview(sample, confirmedStyle)); + return wrap; +} + +function renderCandidateThemeGallery() { + var grid = $("#candidateThemeGrid"); + if (!grid.length) { + return; + } + + var sample = getCandidatePreviewSample(); + grid.empty(); + for (var i = 0; i < candidateThemeNames.length; ++i) { + var themeName = candidateThemeNames[i]; + var card = $("