diff --git a/README.md b/README.md index cdb3af1..62642ea 100644 --- a/README.md +++ b/README.md @@ -62,25 +62,20 @@ - 高風險快速鍵與返回行為維持可用。 - 若執行於 Wine/Proton,開啟螢幕鍵盤時會優先嘗試使用 **Steam 虛擬鍵盤**。 - **Steam Deck 遊戲模式(Gamescope)**: - - 會套用單一視窗與焦點安全限制,避免切換視窗、彈出選單或視窗狀態變更造成黑畫面、失焦或算繪表面異常。 + - 以無邊框全螢幕模式運行,由 Gamescope 合成器接管視窗佈局與焦點管理;視窗還原(`SW_RESTORE`)、智慧邊界定位等 Windows 視窗行為會自動略過,避免干擾合成器的全螢幕表面。 - 開啟螢幕鍵盤時會優先使用 **Steam 虛擬鍵盤**。 - - 下列功能目前會停用或受限制: - - 返回前一個視窗(包含複製成功後自動返回)。 - - 視窗透明度調整與重設。 - - 隱私模式快捷切換。 - - 右鍵選單與說明對話框。 - - 重新朗讀全文(R3)。 - - `LT`/`RT` 的控制器快捷功能,避免與 Steam 虛擬鍵盤的保留按鍵衝突。 + - 部分功能因 Gamescope 環境限制,行為會與 Windows 桌面有所不同: + - **視窗透明度**:Gamescope 合成器不支援 Win32 層級視窗透明度,調整不透明度無效。 + - **音效提示**:Gamescope 音訊沙盒限制,應用程式層級音效無法播放。 + - **A11y 廣播功能**:Gamescope 與 Wine/Proton 環境中無可用的螢幕閱讀器,廣播無作用。 + - **`LT`/`RT` 的控制器快捷功能**:仍可能與 Steam 虛擬鍵盤的保留按鍵衝突。 + - 若 Gamescope 畫面變黑或應用程式失去焦點,可使用下列方式嘗試恢復;請僅在確有需要時使用,頻繁或不必要地觸發可能造成副作用,若恢復後行為仍不正常,建議直接重新啟動應用程式: + - **控制器救援組合鍵**:按住 **LB + RB**,再按下**上側鍵(Y/△/X)**,觸發視窗算繪表面重建。 + - **右鍵選單**:透過觸控螢幕或觸控板開啟右鍵選單,選擇「**恢復 Gamescope 畫面**」。 - **注意事項**: - 本專案目前的 Linux 相容性調整,重點在於 **Steam Deck/SteamOS 3 + Wine/Proton** 的實際使用情境。 - 即使在桌面模式下可保留完整功能,螢幕鍵盤、輸入法候選窗與焦點恢復行為仍可能受桌面環境設定影響。 - - 若應用程式在 Steam Deck 上無法正常啟動、顯示異常或行為不穩定,可嘗試改用較新的 **Proton** 版本,或切換至 **Proton Experimental** 再重新測試。 - - 若要在 SteamOS 3 的 Steam 啟動選項中明確指定正體中文語系,可加入: - ```shell - HOST_LC_ALL=zh_TW.UTF-8 %command% - ``` - - 此設定可讓 Wine/Proton 優先繼承 `zh_TW.UTF-8` 語系,改善介面語系、資源挑選與部分在地化行為的穩定性。 - - **前提**:SteamOS 本機需先生成對應的 `zh_TW.UTF-8` 語系;若系統尚未提供該 locale,則這個啟動參數不會生效,Wine/Proton 仍可能退回至預設語系。 + - 若應用程式在遊戲模式下出現難以解釋的異常行為,建議直接重新啟動應用程式,而非持續嘗試各種恢復操作。 ## 三、使用方式 📖 @@ -131,7 +126,7 @@ 本應用程式支援動態 **控制器主按鍵配置模式**。畫面上的按鍵提示、助記詞與實際功能會隨目前設定同步切換,避免 Xbox、PlayStation 與 Nintendo 控制器在顯示與操作上不一致。 -- **自動**:在使用 **GameInput** 且成功辨識裝置時,會自動選用最合適的配置。PlayStation 預設採 **PlayStation(× 確認)**,Nintendo 採 Nintendo 配置,其餘則以 Xbox 配置為準。 +- **自動**:在使用 **GameInput** 且成功辨識裝置時,會自動選用最合適的配置。PlayStation 預設採 **PlayStation(× 確認)**,Nintendo 採 Nintendo 配置,其餘則以 Xbox 配置為準。使用 **XInput** 時,無法辨識裝置型別,自動模式一律套用 **Xbox** 配置。 - **手動指定優先**:若您在選單中手動指定 Xbox、PlayStation 或 Nintendo 配置,會優先於自動判斷。 - **按鍵名稱對照**:本文中的 **Start 鍵** 與 **Back 鍵**,在部分新式控制器上會分別標示為 **Menu** 與 **View**;實際功能以本文說明為準。 @@ -253,7 +248,7 @@ - 輕推搖桿至藍點剛好碰到實線環(Enter)時,狀態列數字即為此時的死區觸發點;可用來確認死區大小是否符合需求。 - 若藍點在靜置時已超出虛線環(Exit),代表校正後的偏移仍超出離開閾值,需提高 `ThumbDeadzoneEnter` 或重設校正狀態。 - **操作**:按「重設目前校正狀態」後,偏移量歸零,兩點立即重合;靜置幾秒後系統會重新學習漂移並自動補償。狀態列下方的數字格式為「死區進入/離開 Enter/Exit」,即目前設定的閾值。 - - **控制器主按鍵配置** 🎯:可在 **自動**、**Xbox**、**PlayStation(○ 確認)**、**PlayStation(× 確認)** 與 **Nintendo** 之間切換。若選擇 **自動** 並使用 **GameInput**,系統會依裝置型別自動判斷最適合的配置;若手動指定,則以手動選擇為準。 + - **控制器主按鍵配置** 🎯:可在 **自動**、**Xbox**、**PlayStation(○ 確認)**、**PlayStation(× 確認)** 與 **Nintendo** 之間切換。若選擇 **自動** 並使用 **GameInput**,系統會依裝置型別自動判斷最適合的配置;使用 **XInput** 時,自動模式無法辨識裝置型別,一律套用 **Xbox** 配置。若手動指定,則以手動選擇為準。 - **重新啟動提示** 🔄:若您變更了需在下次啟動後才完整套用的項目(例如系統主題/高對比狀態、遊戲控制器輸入 API 或歷程容量),標題列與右鍵選單會同步顯示提示,並提供「立即重新啟動程式」快捷項目。 - **開啟資料夾** 📂:快速開啟位於 `%AppData%\InputBox` 的應用程式資料儲存路徑,方便手動備份或修改設定檔。 - **開啟日誌資料夾** 📋:快速開啟位於 `%LocalAppData%\InputBox\Logs` 的日誌檔案存放路徑,方便查閱或回報錯誤記錄。若日誌資料夾尚未建立(即程式從未發生例外),會以警告對話框與螢幕報讀雙重方式提示資料夾不存在。 @@ -307,7 +302,7 @@ | 參數名稱 | 類型 | 預設值 | 有效範圍 | 說明 | | :--- | :--- | :--- | :--- | :--- | | `GamepadProviderType` | 字串 | `"XInput"` | - | 選擇遊戲控制器輸入 API:`"XInput"` 或 `"GameInput"`。若 GameInput 初始化失敗將自動退避至 XInput。**備註**:使用 **GameInput** 時,遊戲控制器可能需在實際產生輸入(如按鍵或搖桿操作)後,才會開始回報狀態。初次連線時若尚未有反應,請稍候或操作遊戲控制器一次即可。 | -| `GamepadFaceButtonModeType` | 字串 | `"Auto"` | `"Auto"`
`"Xbox"`
`"PlayStationTraditional"`
`"PlayStationCrossConfirm"`
`"Nintendo"` | 選擇控制器主按鍵配置模式。`"Auto"` 會在可辨識裝置型別時自動套用對應的顯示與功能對照;偵測到 PlayStation 時預設優先使用 **PlayStation(× 確認)**,Nintendo 則採 Nintendo 配置;若手動指定模式,會優先於自動判斷。 | +| `GamepadFaceButtonModeType` | 字串 | `"Auto"` | `"Auto"`
`"Xbox"`
`"PlayStationTraditional"`
`"PlayStationCrossConfirm"`
`"Nintendo"` | 選擇控制器主按鍵配置模式。`"Auto"` 會在可辨識裝置型別時自動套用對應的顯示與功能對照;偵測到 PlayStation 時預設優先使用 **PlayStation(× 確認)**,Nintendo 則採 Nintendo 配置。使用 **XInput** 時,自動模式無法辨識裝置型別,一律套用 **Xbox** 配置。若手動指定模式,會優先於自動判斷。 | | `ThumbDeadzoneEnter` | 整數 | `7849` | `0 ~ 30000` | XInput 標準值(0~32767),無單位。搖桿推動觸發閾值。若控制器搖桿因磨損而產生偏移,可提高此值。 | | `ThumbDeadzoneExit` | 整數 | `2500` | `0 ~ 30000` | XInput 標準值(0~32767),無單位。搖桿回彈重置閾值。此值必須顯著低於觸發閾值以防止抖動。 | | `RepeatInitialDelayFrames` | 整數(幀) | `30` | `1 ~ 300` | 長按方向鍵時,開始重複輸入前的延遲(幀)。1 幀約為 16.6ms(60 FPS)。 | @@ -416,16 +411,67 @@ Windows 設定 → 時間與語言 → 輸入 → 觸控式鍵盤 → 「顯示 - 將 **Armory Crate SE** 的控制模式手動改為「桌面」。 - 直接使用觸控螢幕進行拖曳操作。 -### 5. 在 Steam Deck 遊戲模式下切換應用程式後,返回本應用程式時輸入框未取得焦點,控制器按鈕無反應 🎮 +### 5. 在 Steam Deck 遊戲模式下,應用程式失去焦點,控制器功能無回應 🎮 -這是遊戲模式(Gamescope)下的視窗焦點限制,屬於預期行為。 +在下列情況下,遊戲模式(Gamescope)可能導致應用程式失去焦點,造成遊戲控制器功能無作用: -**解決方法** ✅: +- **偶發**:複製文字到剪貼簿後。 +- **切換**:在多個執行中的 Gamescope 之間切換,再切回本應用程式時。 + +**解決方法** ✅(任選其一): + +- **觸控螢幕**:用手指直接點擊輸入框,讓應用程式取得焦點。 +- **觸控板**:以觸控板搭配 **Steam 鍵 + R2(RT)** 觸發滑鼠左鍵點擊,點擊輸入框以恢復焦點。 +- **控制器救援組合鍵**:按住 **LB + RB**,再按下**上側鍵(Y/△/X)**,強制重建視窗算繪表面,恢復焦點與控制器操作。 +- **右鍵選單**:透過觸控螢幕或觸控板開啟右鍵選單,選擇「**恢復 Gamescope 畫面**」。 + +> ⚠️ **注意**:觸控板搭配 Steam 鍵 + R2(RT)的操作,需先在 Steam 的**控制器設定**中,將觸控板的對應動作設為「滑鼠」模式,並確認相關組合鍵已啟用。 + +### 6. 長時間使用或 Steam Deck 休眠後喚醒,Steam 虛擬鍵盤無法叫出 ⌨️ + +在 Steam Deck 遊戲模式下,長時間維持應用程式開啟,或 Steam Deck 休眠後重新喚醒,Steam 虛擬鍵盤可能因內部狀態失效而無法正常呼叫。 + +**解決方法** ✅:重新啟動應用程式。 + +### 7. 在 Steam Deck 遊戲模式下,透明度調整無效 🌓 + +這是預期行為。Gamescope 合成器不支援 Win32 層級視窗透明度(`WS_EX_LAYERED`),因此在遊戲模式下調整不透明度不會有任何視覺效果。 + +### 8. 在 Steam Deck 遊戲模式下,音效提示無聲 🔊 + +這是預期行為。Gamescope 的音訊沙盒環境限制了應用程式層級音效的播放,音效提示在遊戲模式下無法作用。 + +### 9. A11y 廣播功能在 Steam Deck、Wine/Proton 與 Gamescope 下無作用 ♿ + +這是預期行為。Steam Deck 的 Gamescope 與 Wine/Proton 環境中沒有可供應用程式使用的螢幕閱讀器或無障礙廣播接收端,A11y 廣播功能無法產生效果。 + +### 10. 在 Steam Deck 上應用程式無法正常啟動、顯示異常或行為不穩定 🐧 + +可嘗試在 Steam 的遊戲屬性中,改用較新的 **Proton** 版本,或切換至 **Proton Experimental** 後重新啟動。 + +### 11. 在 SteamOS 3 遊戲模式下,介面語系顯示不正確 🌐 + +可在 Steam 的啟動選項中加入下列設定,讓 Wine/Proton 優先繼承指定語系: + +```shell +HOST_LC_ALL= %command% +``` + +將 `` 替換為對應的語系識別碼。以下列出本應用程式支援語系的常見對應值: + +| 語系 | `` 值 | +| :--- | :--- | +| 德文 | `de_DE.UTF-8` | +| 英文 | `en_US.UTF-8` | +| 法文 | `fr_FR.UTF-8` | +| 日文 | `ja_JP.UTF-8` | +| 韓文 | `ko_KR.UTF-8` | +| 簡體中文 | `zh_CN.UTF-8` | +| 正體中文 | `zh_TW.UTF-8` | -- **觸控**:用手指直接點擊輸入框一下,即可讓輸入框取得焦點。 -- **觸控板**:以觸控板搭配 **Steam 鍵 + 滑鼠右鍵(R1)**,將游標移至輸入框後按下確認,即可恢復控制器操作。 +此設定可讓 Wine/Proton 優先繼承指定語系,改善介面語系、資源挑選與部分在地化行為的穩定性。 -> ⚠️ **注意**:使用觸控板搭配 Steam 鍵 + 滑鼠右鍵(R1)的操作方式,需先在 Steam 的**控制器設定**中,將觸控板的對應動作配置為「滑鼠」模式,並確認相關組合鍵已啟用,才能正常使用。 +> **前提**:SteamOS 本機需先生成對應的 locale。若系統尚未提供,此啟動參數不會生效,Wine/Proton 仍可能退回預設語系。可在 SteamOS 的桌面模式終端機以 `locale -a` 確認目前已生成的 locale 清單。 ## 六、應用程式的設計原則 🛡️ diff --git a/src/InputBox/Core/Controls/GamepadCalibrationDialog.cs b/src/InputBox/Core/Controls/GamepadCalibrationDialog.cs index 0fad1c2..bd46aba 100644 --- a/src/InputBox/Core/Controls/GamepadCalibrationDialog.cs +++ b/src/InputBox/Core/Controls/GamepadCalibrationDialog.cs @@ -511,6 +511,7 @@ private void SubscribeGamepadEvents() _gamepadController.StartPressed += HandleGamepadConfirm; _gamepadController.BPressed += profile.ConfirmOnSouth ? HandleGamepadCancel : HandleGamepadConfirm; _gamepadController.BackPressed += HandleGamepadCancel; + _gamepadController.YPressed += HandleGamescopeSurfaceRecovery; _gamepadController.LeftPressed += HandleDPadPrevious; _gamepadController.LeftRepeat += HandleDPadPrevious; _gamepadController.UpPressed += HandleDPadPrevious; @@ -540,6 +541,7 @@ private void UnsubscribeGamepadEvents() _gamepadController.BPressed -= HandleGamepadConfirm; _gamepadController.BPressed -= HandleGamepadCancel; _gamepadController.BackPressed -= HandleGamepadCancel; + _gamepadController.YPressed -= HandleGamescopeSurfaceRecovery; _gamepadController.LeftPressed -= HandleDPadPrevious; _gamepadController.LeftRepeat -= HandleDPadPrevious; _gamepadController.UpPressed -= HandleDPadPrevious; @@ -654,6 +656,18 @@ private void HandleGamepadCancel() } } + /// + /// 處理 Gamescope 專用 surface recovery 組合鍵。 + /// + private void HandleGamescopeSurfaceRecovery() + { + GamescopeSurfaceRecovery.TryRecoverFromGamepadChord( + this, + RecreateHandle, + _gamepadController, + context: "GamepadCalibrationDialog Gamescope surface recovery 失敗"); + } + /// /// 處理 D-Pad 向前(左/上)輸入,在搖桿不干擾時移動焦點至前一個按鈕。 /// @@ -1201,4 +1215,4 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } -} \ No newline at end of file +} diff --git a/src/InputBox/Core/Controls/GamepadMessageBox.cs b/src/InputBox/Core/Controls/GamepadMessageBox.cs index 1f34004..d52540d 100644 --- a/src/InputBox/Core/Controls/GamepadMessageBox.cs +++ b/src/InputBox/Core/Controls/GamepadMessageBox.cs @@ -85,6 +85,7 @@ private void SubscribeGamepadEvents() _gamepadController.StartPressed += HandleGamepadA; _gamepadController.BPressed += profile.ConfirmOnSouth ? HandleGamepadB : HandleGamepadA; _gamepadController.BackPressed += HandleGamepadB; + _gamepadController.YPressed += HandleGamescopeSurfaceRecovery; _gamepadController.LeftPressed += HandleDPadLeft; _gamepadController.LeftRepeat += HandleDPadLeft; _gamepadController.RightPressed += HandleDPadRight; @@ -110,6 +111,7 @@ private void UnsubscribeGamepadEvents() _gamepadController.BPressed -= HandleGamepadA; _gamepadController.BPressed -= HandleGamepadB; _gamepadController.BackPressed -= HandleGamepadB; + _gamepadController.YPressed -= HandleGamescopeSurfaceRecovery; _gamepadController.LeftPressed -= HandleDPadLeft; _gamepadController.LeftRepeat -= HandleDPadLeft; _gamepadController.RightPressed -= HandleDPadRight; @@ -994,6 +996,18 @@ private void HandleGamepadB() }); } + /// + /// 處理 Gamescope 專用 surface recovery 組合鍵。 + /// + private void HandleGamescopeSurfaceRecovery() + { + GamescopeSurfaceRecovery.TryRecoverFromGamepadChord( + this, + RecreateHandle, + _gamepadController, + context: "GamepadMessageBox Gamescope surface recovery 失敗"); + } + /// /// 處理遊戲控制器 DPad 左鍵事件,將焦點移動到上一個可用按鈕,並播報新焦點按鈕名稱給螢幕閱讀器 /// @@ -1119,31 +1133,6 @@ public static DialogResult Show( MessageBoxDefaultButton defaultButton = MessageBoxDefaultButton.Button1, IGamepadController? gamepad = null) { - // Gamescope (遊戲模式) 防護: - // 攔截所有會建立新視窗表面的 ShowDialog 呼叫,改為 Log 紀錄並回傳預設值。 - // 這能防止 Gamescope 合成器因視窗切換導致的主介面渲染鏈中斷與黑屏。 - if (SystemHelper.IsRunningOnGamescope()) - { - LoggerService.LogInfo($"[Gamescope Intercept] {caption}: {text} (Buttons: {buttons})"); - - if (gamepad != null) - { - FeedbackService.VibrateAsync(gamepad, VibrationPatterns.ActionFail, CancellationToken.None).SafeFireAndForget(); - } - - // 回傳安全預設值:單一按鈕回傳 OK,確認型按鈕回傳 No 或 Cancel。 - return buttons switch - { - MessageBoxButtons.OK => DialogResult.OK, - MessageBoxButtons.OKCancel => DialogResult.Cancel, - MessageBoxButtons.YesNo => DialogResult.No, - MessageBoxButtons.YesNoCancel => DialogResult.Cancel, - MessageBoxButtons.RetryCancel => DialogResult.Cancel, - MessageBoxButtons.AbortRetryIgnore => DialogResult.Ignore, - _ => DialogResult.OK, - }; - } - using GamepadMessageBox dlg = new(text, caption, buttons, icon, defaultButton); dlg.GamepadController = gamepad; @@ -1186,4 +1175,4 @@ public static DialogResult Show( MessageBoxDefaultButton defaultButton = MessageBoxDefaultButton.Button1, IGamepadController? gamepad = null) => Show(null, text, caption, buttons, icon, defaultButton, gamepad); -} \ No newline at end of file +} diff --git a/src/InputBox/Core/Controls/HelpDialog.cs b/src/InputBox/Core/Controls/HelpDialog.cs index dff4307..ca4f3e2 100644 --- a/src/InputBox/Core/Controls/HelpDialog.cs +++ b/src/InputBox/Core/Controls/HelpDialog.cs @@ -894,6 +894,7 @@ private void BindGamepadEvents() GamepadController.BackPressed += OnGamepadClose; GamepadController.StartPressed += OnGamepadClose; GamepadController.APressed += OnGamepadClose; + GamepadController.YPressed += OnGamepadSurfaceRecovery; GamepadController.LeftTriggerPressed += OnGamepadPageUp; GamepadController.RightTriggerPressed += OnGamepadPageDown; GamepadController.LeftTriggerRepeat += OnGamepadPageUp; @@ -919,6 +920,7 @@ private void UnbindGamepadEvents() GamepadController.BackPressed -= OnGamepadClose; GamepadController.StartPressed -= OnGamepadClose; GamepadController.APressed -= OnGamepadClose; + GamepadController.YPressed -= OnGamepadSurfaceRecovery; GamepadController.LeftTriggerPressed -= OnGamepadPageUp; GamepadController.RightTriggerPressed -= OnGamepadPageDown; GamepadController.LeftTriggerRepeat -= OnGamepadPageUp; @@ -1038,6 +1040,18 @@ private void OnGamepadClose() }); } + /// + /// 處理 Gamescope 專用 surface recovery 組合鍵。 + /// + private void OnGamepadSurfaceRecovery() + { + GamescopeSurfaceRecovery.TryRecoverFromGamepadChord( + this, + RecreateHandle, + GamepadController, + context: "HelpDialog Gamescope surface recovery 失敗"); + } + #endregion #region 關閉按鈕視覺特效 @@ -1076,4 +1090,4 @@ private void UpdateButtonMinimumSize() } #endregion -} \ No newline at end of file +} diff --git a/src/InputBox/Core/Controls/NumericInputDialog.cs b/src/InputBox/Core/Controls/NumericInputDialog.cs index ed8bce6..a49c771 100644 --- a/src/InputBox/Core/Controls/NumericInputDialog.cs +++ b/src/InputBox/Core/Controls/NumericInputDialog.cs @@ -1051,6 +1051,15 @@ private void HandleReset() => this.SafeInvoke(() => { try { + if (GamescopeSurfaceRecovery.TryRecoverFromGamepadChord( + this, + RecreateHandle, + _gamepadController, + context: "NumericInputDialog Gamescope surface recovery 失敗")) + { + return; + } + if (IsDisposed || !IsHandleCreated || _nud == null) @@ -2411,4 +2420,4 @@ void StopRepeat() btn.Disposed += (s, e) => StopRepeat(); } -} \ No newline at end of file +} diff --git a/src/InputBox/Core/Controls/PhraseEditDialog.cs b/src/InputBox/Core/Controls/PhraseEditDialog.cs index 660579a..c9b51dc 100644 --- a/src/InputBox/Core/Controls/PhraseEditDialog.cs +++ b/src/InputBox/Core/Controls/PhraseEditDialog.cs @@ -1624,6 +1624,16 @@ private void HandleOpenContextMenu() => this.SafeInvoke(() => { try { + if (GamescopeSurfaceRecovery.TryRecoverFromGamepadChord( + this, + RecreateHandle, + _gamepadController, + beforeRecover: CloseActiveTextBoxContextMenu, + context: "PhraseEditDialog Gamescope surface recovery 失敗")) + { + return; + } + TextBox? tb = GetActiveTextBox(); if (tb == null) @@ -1648,6 +1658,14 @@ private void HandleOpenContextMenu() => this.SafeInvoke(() => } }); + /// + /// 在重建 PhraseEditDialog surface 前關閉目前 TextBox 的右鍵選單。 + /// + private void CloseActiveTextBoxContextMenu() + { + GetActiveTextBox()?.ContextMenuStrip?.Close(); + } + /// /// 擴張或縮減文字選取範圍(比照 MainForm.Gamepad.cs 的 ExpandSelection) /// @@ -2181,4 +2199,4 @@ private void ApplySmartPosition() Location = clampedLocation; } } -} \ No newline at end of file +} diff --git a/src/InputBox/Core/Controls/PhraseManagerDialog.cs b/src/InputBox/Core/Controls/PhraseManagerDialog.cs index 30bc49c..00f3d5b 100644 --- a/src/InputBox/Core/Controls/PhraseManagerDialog.cs +++ b/src/InputBox/Core/Controls/PhraseManagerDialog.cs @@ -1261,6 +1261,15 @@ private void HandleAdd() => this.SafeInvoke(() => { try { + if (GamescopeSurfaceRecovery.TryRecoverFromGamepadChord( + this, + RecreateHandle, + _gamepadController, + context: "PhraseManagerDialog Gamescope surface recovery 失敗")) + { + return; + } + if (!CanHandleGamepadInput()) { return; @@ -2001,4 +2010,4 @@ await this.SafeInvokeAsync(() => } #endregion -} \ No newline at end of file +} diff --git a/src/InputBox/Core/Extensions/TaskExtensions.cs b/src/InputBox/Core/Extensions/TaskExtensions.cs index 932e640..85522bb 100644 --- a/src/InputBox/Core/Extensions/TaskExtensions.cs +++ b/src/InputBox/Core/Extensions/TaskExtensions.cs @@ -138,15 +138,9 @@ private static async Task ExecuteSafeFireAndForgetInternalAsync( } catch (Exception ex) { - // 記錄至檔案系統。 - LoggerService.LogException(ex, "背景任務 SafeFireAndForget 發生未捕捉例外"); - - // 記錄至 Debug 視窗。 - Debug.WriteLine($"[背景任務例外] {ex.Message}"); - try { - // 如果有提供額外的處理動作(如記錄至日誌或彈出視窗),則執行它。 + // 先執行回呼,避免被後續的磁碟 I/O 延誤。 onException?.Invoke(ex); // 執行全域處理(使用快照確保原子性)。 @@ -158,6 +152,12 @@ private static async Task ExecuteSafeFireAndForgetInternalAsync( { Debug.WriteLine($"[背景任務例外] 例外處理程序發生錯誤:{secondaryEx.Message}"); } + + // 記錄至檔案系統(可能有 I/O 延遲,放在回呼之後)。 + LoggerService.LogException(ex, "背景任務 SafeFireAndForget 發生未捕捉例外"); + + // 記錄至 Debug 視窗。 + Debug.WriteLine($"[背景任務例外] {ex.Message}"); } } } \ No newline at end of file diff --git a/src/InputBox/Core/Services/CmdKeyDispatcher.cs b/src/InputBox/Core/Services/CmdKeyDispatcher.cs index 1217e6d..75af5b7 100644 --- a/src/InputBox/Core/Services/CmdKeyDispatcher.cs +++ b/src/InputBox/Core/Services/CmdKeyDispatcher.cs @@ -99,7 +99,6 @@ public static bool TryGetContextMenuAction(Keys keyData, bool menuVisible, out s /// 回傳輸入框是否可聚焦。 /// 聚焦輸入框的動作。 /// 播報略過導覽訊息的動作。 - /// 是否停用高風險快捷鍵。 /// 若命令鍵已被處理則回傳 true。 public static bool TryHandleGlobal( Keys keyData, @@ -110,21 +109,8 @@ public static bool TryHandleGlobal( Action onShowContextMenu, Func canFocusInput, Action onFocusInput, - Action onAnnounceSkipNav, - bool restrictHighRiskShortcuts = false) + Action onAnnounceSkipNav) { - if (restrictHighRiskShortcuts && - keyData is (Keys.Alt | Keys.B) or - (Keys.Alt | Keys.Up) or - (Keys.Alt | Keys.Down) or - (Keys.Alt | Keys.D0) or - (Keys.Alt | Keys.P) or - Keys.F10 or - (Keys.Alt | Keys.M)) - { - return true; - } - switch (keyData) { case Keys.Alt | Keys.B: @@ -221,4 +207,4 @@ public static InputBoxCmdResult HandleInputBox( return InputBoxCmdResult.Unhandled; } -} \ No newline at end of file +} diff --git a/src/InputBox/Core/Services/TouchKeyboardService.cs b/src/InputBox/Core/Services/TouchKeyboardService.cs index e0aae7e..b4a7608 100644 --- a/src/InputBox/Core/Services/TouchKeyboardService.cs +++ b/src/InputBox/Core/Services/TouchKeyboardService.cs @@ -72,14 +72,26 @@ public static bool TryOpen() try { - // 策略 1:優先使用 COM 介面(ITipInvocation)。 + // 策略 1:Wine / Proton 或 Gamescope 環境下,COM 不可用,改用 Steam URI scheme。 + if (SystemHelper.IsRunningOnWine() || SystemHelper.IsRunningOnGamescope()) + { + bool opened = TryOpenSteamKeyboard(); + + Debug.WriteLine(opened + ? "[TryOpen] Wine/Gamescope:已透過 Steam URI scheme 喚起螢幕鍵盤。" + : "[TryOpen] Wine/Gamescope:Steam URI scheme 喚起失敗。"); + + return opened; + } + + // 策略 2:優先使用 COM 介面(ITipInvocation)。 // 在 Windows 10 週年更新之後,使用 COM 介面能更穩定地向背景服務發送 Toggle 指令。 if (TryOpenViaCOM()) { return true; } - // 策略 2:Fallback 至啟動 TabTip.exe 程式。 + // 策略 3:Fallback 至啟動 TabTip.exe 程式。 string? strTabTipPath = SystemHelper.GetTabTipPath(); if (string.IsNullOrEmpty(strTabTipPath)) diff --git a/src/InputBox/Core/Utilities/GamescopeSurfaceRecovery.cs b/src/InputBox/Core/Utilities/GamescopeSurfaceRecovery.cs new file mode 100644 index 0000000..44ced27 --- /dev/null +++ b/src/InputBox/Core/Utilities/GamescopeSurfaceRecovery.cs @@ -0,0 +1,132 @@ +using InputBox.Core.Input; +using InputBox.Core.Services; +using System.Diagnostics; + +namespace InputBox.Core.Utilities; + +/// +/// Gamescope 下用於重建 WinForms top-level surface 的共用救援流程。 +/// +internal static class GamescopeSurfaceRecovery +{ + /// + /// 防止多個 Form 或連續控制器事件同時重建 HWND。 + /// + private static int _isRecoveringSurface; + + /// + /// 判斷目前控制器狀態是否符合 Gamescope surface recovery 組合鍵。 + /// + /// 目前控制器。 + /// 符合 LB + RB 修飾鍵且正在 Gamescope 下執行時為 true。 + public static bool IsRecoveryChordActive(IGamepadController? controller) + { + return SystemHelper.IsRunningOnGamescope() && + controller?.IsLeftShoulderHeld == true && + controller.IsRightShoulderHeld; + } + + /// + /// 若目前按鍵符合 Gamescope surface recovery 組合鍵,重建指定 Form 的 HWND。 + /// + /// 要重建 surface 的 top-level Form。 + /// 由目標 Form 傳入的 執行委派。 + /// 目前控制器。 + /// 重建前的選單或 popup 清理流程。 + /// 重建後的視窗樣式或焦點還原流程。 + /// 記錄錯誤時使用的情境字串。 + /// 若已處理 recovery chord 則為 true;否則為 false。 + public static bool TryRecoverFromGamepadChord( + Form target, + Action recreateHandle, + IGamepadController? controller, + Action? beforeRecover = null, + Action? afterRecover = null, + string? context = null) + { + if (!IsRecoveryChordActive(controller)) + { + return false; + } + + RecoverFormSurface(target, recreateHandle, beforeRecover, afterRecover, context); + + return true; + } + + /// + /// 重建指定 Form 的 HWND,讓 Gamescope 重新取得可合成的 top-level surface。 + /// + /// 要重建 surface 的 top-level Form。 + /// 由目標 Form 傳入的 執行委派。 + /// 重建前的選單或 popup 清理流程。 + /// 重建後的視窗樣式或焦點還原流程。 + /// 記錄錯誤時使用的情境字串。 + public static void RecoverFormSurface( + Form target, + Action recreateHandle, + Action? beforeRecover = null, + Action? afterRecover = null, + string? context = null) + { + if (!SystemHelper.IsRunningOnGamescope() || + target.IsDisposed) + { + return; + } + + // 同一時間只允許一個 top-level Form 重建 HWND,避免 popup/dialog 鏈同時失效。 + if (Interlocked.Exchange(ref _isRecoveringSurface, 1) != 0) + { + return; + } + + // 重建後嘗試恢復原本的焦點控制項;若控制項已釋放或不可聚焦則略過。 + Control? activeControl = target.ActiveControl; + + try + { + beforeRecover?.Invoke(); + + target.SuspendLayout(); + + if (!target.Visible) + { + target.Show(); + } + + if (target.IsHandleCreated) + { + recreateHandle(); + } + + if (!target.Visible) + { + target.Show(); + } + + afterRecover?.Invoke(); + + target.BringToFront(); + target.Activate(); + + if (activeControl is { IsDisposed: false, CanFocus: true }) + { + activeControl.Focus(); + } + } + catch (Exception ex) + { + // context 用於區分是哪一種 Form 觸發 recovery,方便 Steam Deck 實機日誌判讀。 + string message = context ?? "Gamescope surface recovery 失敗"; + + LoggerService.LogException(ex, message); + Debug.WriteLine($"[{nameof(GamescopeSurfaceRecovery)}] {message}:{ex.Message}"); + } + finally + { + target.ResumeLayout(performLayout: true); + Interlocked.Exchange(ref _isRecoveringSurface, 0); + } + } +} diff --git a/src/InputBox/Core/Utilities/SystemHelper.cs b/src/InputBox/Core/Utilities/SystemHelper.cs index 54b61d1..2edf4b5 100644 --- a/src/InputBox/Core/Utilities/SystemHelper.cs +++ b/src/InputBox/Core/Utilities/SystemHelper.cs @@ -45,32 +45,6 @@ public static bool IsRunningOnGamescope() return _isOnGamescope; } - /// - /// 判斷目前平台是否應限制高風險快捷鍵與自動返回行為。 - /// - /// - /// 只有 Gamescope(SteamOS 遊戲模式)需要限制依賴明確播報或前景切換的高風險操作; - /// Steam Deck 桌面模式(KDE Plasma)下即使執行於 Wine / Proton,也應保留完整功能。 - /// - /// 若應限制高風險快捷鍵則回傳 true。 - public static bool ShouldRestrictHighRiskShortcuts() - { - return _isOnGamescope; - } - - /// - /// 判斷目前平台是否應停用 Steam 螢幕鍵盤保留的板機快捷功能。 - /// - /// - /// Gamescope 下 Steam 螢幕鍵盤會占用 LT 與 RT; - /// 此時應停用應用程式內依賴板機的快捷邏輯,避免與 OSK 映射衝突。 - /// - /// 若應停用 Steam 螢幕鍵盤保留的板機快捷功能則回傳 true。 - public static bool ShouldRestrictSteamKeyboardTriggerShortcuts() - { - return _isOnGamescope; - } - /// /// 在程式啟動時執行一次 Wine 偵測,結果快取至 /// diff --git a/src/InputBox/Core/Utilities/WineLocaleBootstrapper.cs b/src/InputBox/Core/Utilities/WineLocaleBootstrapper.cs deleted file mode 100644 index 8978cd6..0000000 --- a/src/InputBox/Core/Utilities/WineLocaleBootstrapper.cs +++ /dev/null @@ -1,171 +0,0 @@ -using System.Globalization; - -namespace InputBox.Core.Utilities; - -/// -/// Wine / Proton 環境下的宿主 locale 橋接器 -/// -/// -/// Steam 會將 LC_ALL 強制設為 "C",並透過 HOST_LC_ALL 保留宿主真實 locale。 -/// Proton 啟動腳本(proton_3.x 起,commit 2ae0d898)在進程建立前已將 HOST_LC_ALL 複製至 LC_ALL, -/// 因此本類別只需讀取 LC_ALL,無需直接處理 HOST_LC_ALL。 -/// 僅在 回傳 時啟用; -/// 原生 Windows 執行時完全跳過,不對 做任何異動。 -/// -internal static class WineLocaleBootstrapper -{ - /// - /// 將宿主 POSIX locale 橋接為 .NET 並套用至所有執行緒。 - /// - /// - /// 必須在 Main() 最前端、任何資源字串存取之前呼叫, - /// 以確保衛星資源組件(zh-Hantzh-Hans 等)在啟動時即以正確語系載入。 - /// 讀取優先順序:LC_ALLLANG。 - /// 若 Wine 偵測失敗、環境變數不存在或 locale 無法對應至有效 , - /// 則靜默略過並保留系統預設。 - /// - internal static void Apply() - { - if (!SystemHelper.IsRunningOnWine()) - { - return; - } - - string? raw = - Environment.GetEnvironmentVariable("LC_ALL") is { Length: > 0 } lcAll ? lcAll : - Environment.GetEnvironmentVariable("LANG") is { Length: > 0 } lang ? lang : - null; - - if (raw is null) - { - return; - } - - string? tag = NormalizePosixToTag(raw); - - if (tag is null) - { - return; - } - - TryApplyCulture(tag); - } - - /// - /// 將 POSIX locale 字串正規化為 .NET culture tag,並映射到本專案支援的 culture。 - /// - /// - /// 原始 POSIX locale 字串,例如 "zh_TW.UTF-8""ja_JP.UTF-8""C"。 - /// - /// - /// 正規化並映射後的 .NET culture tag(例如 "zh-Hant""ja-JP"); - /// 若輸入為空白、"C""POSIX" 等不應套用的值則回傳 。 - /// - internal static string? NormalizePosixToTag(string posixLocale) - { - if (string.IsNullOrWhiteSpace(posixLocale)) - { - return null; - } - - ReadOnlySpan span = posixLocale.AsSpan().Trim(); - - // 去除編碼後綴:zh_TW.UTF-8 → zh_TW - int dot = span.IndexOf('.'); - if (dot > 0) - { - span = span[..dot]; - } - - // 去除修飾詞:en_US@euro → en_US - int at = span.IndexOf('@'); - if (at > 0) - { - span = span[..at]; - } - - string tag = span.ToString().Replace('_', '-'); - - // "C" 與 "POSIX" 代表 Steam 強制設定的中性值,不應套用 - if (tag is "C" or "POSIX") - { - return null; - } - - return MapToSupportedCulture(tag); - } - - /// - /// 將 culture tag 映射到本專案資源目錄中存在的 culture 名稱。 - /// - /// - /// - /// 繁體中文各地區的 2 組件與 3 組件 ICU 格式均統一映射至 zh-Hant; - /// 簡體中文各地區的 2 組件與 3 組件 ICU 格式均統一映射至 zh-Hans。 - /// - /// - /// 香港與澳門在 .NET 10 SpecificCultures 同時存在 zh-Hans-*(簡體) - /// 與 zh-Hant-*(繁體)兩種規格;3 組件格式可精確分流, - /// 2 組件 zh-HK / zh-MO 依 Linux glibc 慣例映射至繁體。 - /// - /// - /// 馬來西亞(zh-MY)的 2 組件形式 parent 為中性 zh(非 zh-Hans), - /// 若直接 pass-through 則 resource fallback 會落回 Invariant 而非 zh-Hans, - /// 因此須明確映射至 zh-Hans(馬來西亞華人社群以簡體為主)。 - /// - /// - /// 其他語系(de-DEja-JP 等)直接回傳原值, - /// 由 .NET CultureInfo 的內建 fallback chain 自動處理衛星組件查找。 - /// - /// - /// - /// 已正規化的 .NET culture tag, - /// 例如 "zh-TW""zh-Hant-TW""zh-MY""ja-JP"。 - /// - /// - /// 映射後的 culture tag;若無需重映射則回傳原始 。 - /// - internal static string MapToSupportedCulture(string tag) - { - return tag switch - { - // 2 組件形式(POSIX 正規化後)—— 繁體 - "zh-TW" or "zh-HK" or "zh-MO" => "zh-Hant", - - // 2 組件形式(POSIX 正規化後)—— 簡體 - // zh-MY 的 parent 為中性 zh,須明確映射至 zh-Hans 避免 fallback 落回 Invariant - "zh-CN" or "zh-SG" or "zh-MY" => "zh-Hans", - - // 3 組件 ICU 正式形式(.NET 10 SpecificCultures 的 canonical name)—— 繁體 - "zh-Hant-TW" or "zh-Hant-HK" or "zh-Hant-MO" or "zh-Hant-MY" => "zh-Hant", - - // 3 組件 ICU 正式形式(.NET 10 SpecificCultures 的 canonical name)—— 簡體 - "zh-Hans-CN" or "zh-Hans-HK" or "zh-Hans-MO" or "zh-Hans-SG" or "zh-Hans-MY" => "zh-Hans", - - _ => tag - }; - } - - /// - /// 將指定 culture tag 套用至目前執行緒與所有後續建立的執行緒。 - /// - /// - /// 有效的 .NET culture tag,例如 "zh-Hant""ja-JP"。 - /// - private static void TryApplyCulture(string tag) - { - try - { - CultureInfo ci = CultureInfo.GetCultureInfo(tag); - - CultureInfo.CurrentCulture = ci; - CultureInfo.CurrentUICulture = ci; - CultureInfo.DefaultThreadCurrentCulture = ci; - CultureInfo.DefaultThreadCurrentUICulture = ci; - } - catch - { - // Wine NLS 不支援或 tag 無效時,靜默略過,保留系統預設 - } - } -} \ No newline at end of file diff --git a/src/InputBox/MainForm.ContextMenu.cs b/src/InputBox/MainForm.ContextMenu.cs index 0cc798d..5f6f36a 100644 --- a/src/InputBox/MainForm.ContextMenu.cs +++ b/src/InputBox/MainForm.ContextMenu.cs @@ -7,7 +7,6 @@ using InputBox.Core.Services; using InputBox.Core.Utilities; using InputBox.Resources; -using System.ComponentModel; using System.Diagnostics; using System.Media; @@ -33,6 +32,11 @@ public partial class MainForm /// private const int PhraseMenuPageSizeSmall = 3; + /// + /// Gamescope 主畫面恢復選單項。 + /// + private ToolStripMenuItem? _tsmiRecoverGamescopeSurface; + /// /// 大尺寸畫面時,最近使用片語的最大顯示筆數。 /// @@ -110,12 +114,6 @@ private void InitializeContextMenu() Strings.A11y_ContextMenu_Name, Strings.A11y_ContextMenu_Desc); - // Gamescope (遊戲模式) 防護: - // 攔截選單開啟事件,防止產生彈出視窗表面破壞渲染鏈。 - // 使用具名處理器(先 -= 再 +=)確保多次呼叫時不重複訂閱。 - _cmsInput.Opening -= OnContextMenuOpening_GamescopeGuard; - _cmsInput.Opening += OnContextMenuOpening_GamescopeGuard; - ContextMenuBuilder.EnsureRestartItem( _cmsInput, IsRestartUpdatePending, @@ -1504,6 +1502,28 @@ void AddFaceLayoutItem(AppSettings.GamepadFaceButtonMode mode) } }; + if (SystemHelper.IsRunningOnGamescope()) + { + _tsmiRecoverGamescopeSurface = new ToolStripMenuItem(Strings.Menu_RecoverGamescopeSurface) + { + AccessibleName = Strings.Menu_RecoverGamescopeSurface, + AccessibleDescription = Strings.Menu_RecoverGamescopeSurface_Desc + }; + _tsmiRecoverGamescopeSurface.Click += (s, e) => + { + try + { + RecoverGamescopeMainSurface(); + } + catch (Exception ex) + { + LoggerService.LogException(ex, "tsmiRecoverGamescopeSurface.Click 失敗"); + + Debug.WriteLine($"[選單] tsmiRecoverGamescopeSurface.Click 失敗:{ex.Message}"); + } + }; + } + _cmsInput.Items.Add(_tsmiPrivacyMode); _cmsInput.Items.Add(_tsmiA11yInterrupt); _cmsInput.Items.Add(_tsmiAnimatedVisualAlerts); @@ -1515,6 +1535,12 @@ void AddFaceLayoutItem(AppSettings.GamepadFaceButtonMode mode) _cmsInput.Items.Add(tsmiSettings); _cmsInput.Items.Add(new ToolStripSeparator()); _cmsInput.Items.Add(tsmiClearHistory); + if (_tsmiRecoverGamescopeSurface != null) + { + _cmsInput.Items.Add(new ToolStripSeparator()); + _cmsInput.Items.Add(_tsmiRecoverGamescopeSurface); + } + _cmsInput.Items.Add(new ToolStripSeparator()); _cmsInput.Items.Add(tsmiHelp); _cmsInput.Items.Add(new ToolStripSeparator()); @@ -1525,6 +1551,72 @@ void AddFaceLayoutItem(AppSettings.GamepadFaceButtonMode mode) TLPHost.ContextMenuStrip = _cmsInput; } + /// + /// 恢復 Gamescope 下 MainForm 的主視窗 surface。 + /// + private void RecoverGamescopeMainSurface() + { + if (IsDisposed || + !SystemHelper.IsRunningOnGamescope()) + { + return; + } + + Show(); + RecreateGamescopeMainSurface(); + } + + /// + /// 在重建 MainForm surface 前關閉右鍵選單 popup 鏈。 + /// + private void CloseContextMenuForSurfaceRecovery() + { + try + { + _cmsInput?.Close(ToolStripDropDownCloseReason.CloseCalled); + } + catch (InvalidOperationException ex) + { + LoggerService.LogException(ex, "Gamescope surface recovery 關閉右鍵選單失敗"); + } + } + + /// + /// 重建 MainForm HWND 並還原 Gamescope 滿版與輸入框狀態。 + /// + private void RecreateGamescopeMainSurface() + { + // 保留使用者目前輸入內容,避免 recovery 導致文字被 WinForms 重建流程清空。 + string inputText = TBInput.Text; + + // 保留游標起點;使用 Math.Min 避免文字長度在 recovery 前後變化造成越界。 + int selectionStart = Math.Min(TBInput.SelectionStart, inputText.Length); + + // 保留選取長度;上限以目前文字剩餘長度計算,避免 SelectionLength 還原時超出範圍。 + int selectionLength = Math.Min(TBInput.SelectionLength, inputText.Length - selectionStart); + + GamescopeSurfaceRecovery.RecoverFormSurface( + this, + RecreateHandle, + afterRecover: () => + { + if (!string.Equals(TBInput.Text, inputText, StringComparison.Ordinal)) + { + TBInput.Text = inputText; + } + + TBInput.SelectionStart = selectionStart; + TBInput.SelectionLength = selectionLength; + + ApplyGamescopeBorderlessFullscreen(); + UpdateLayoutConstraints(); + + _ = User32.BringWindowToTop(Handle); + TryFocusInputControl(); + }, + context: "Gamescope MainForm surface recovery 失敗"); + } + /// /// 重新整理整個右鍵選單的標籤文字與 A11y 描述。 /// @@ -1547,6 +1639,12 @@ public void RefreshMenu() () => AskForRestart(RestartRequestSource.ManualMenu), RestartMenuAccessibleDescription); + if (_tsmiRecoverGamescopeSurface != null) + { + _tsmiRecoverGamescopeSurface.Text = Strings.Menu_RecoverGamescopeSurface; + _tsmiRecoverGamescopeSurface.AccessibleName = Strings.Menu_RecoverGamescopeSurface; + _tsmiRecoverGamescopeSurface.AccessibleDescription = Strings.Menu_RecoverGamescopeSurface_Desc; + } foreach (ToolStripItem item in _cmsInput.Items) { @@ -1740,30 +1838,42 @@ private static void RefreshMenuText(ToolStripMenuItem parent) /// private void ShowContextMenuAtInput() { - if (IsDisposed || - TBInput == null || - TBInput.IsDisposed || - _cmsInput == null) + if (!TryShowContextMenuAtInput()) { return; } - if (!_cmsInput.Visible) + if (ContextMenuBuilder.TrySelectFirstVisibleItem( + _cmsInput!, + Strings.A11y_Checked, + Strings.A11y_Unchecked, + out string announcement)) { - // 在文字方塊下方開啟選單。 - _cmsInput.Show(this, new Point(TBInput.Left, TBInput.Bottom)); + AnnounceA11y(announcement, interrupt: true); + } - if (ContextMenuBuilder.TrySelectFirstVisibleItem( - _cmsInput, - Strings.A11y_Checked, - Strings.A11y_Unchecked, - out string announcement)) - { - AnnounceA11y(announcement, interrupt: true); - } + VibrateAsync(VibrationPatterns.CursorMove).SafeFireAndForget(); + } - VibrateAsync(VibrationPatterns.CursorMove).SafeFireAndForget(); + /// + /// 嘗試在輸入框下方顯示右鍵選單,並回報選單是否真的可見。 + /// + /// 若選單已成功顯示則回傳 true。 + private bool TryShowContextMenuAtInput() + { + if (IsDisposed || + TBInput == null || + TBInput.IsDisposed || + _cmsInput == null || + _cmsInput.Visible) + { + return false; } + + _cmsInput.Show(this, new Point(TBInput.Left, TBInput.Bottom)); + + // Opening 事件可能取消顯示;只有選單真的可見時才執行後續選取與回饋。 + return _cmsInput.Visible; } /// @@ -2437,20 +2547,6 @@ private void EnsurePhraseSubMenuReadyForKeyboard() SelectPreferredPhraseMenuItem(PhraseMenuSelectionTarget.FirstPhrase); } - /// - /// Gamescope(遊戲模式)防護:攔截右鍵選單開啟事件,防止彈出視窗破壞渲染鏈。 - /// - /// 事件來源()。 - /// 取消事件引數;設定 為 true 可阻止選單顯示。 - private void OnContextMenuOpening_GamescopeGuard(object? sender, CancelEventArgs e) - { - if (SystemHelper.IsRunningOnGamescope()) - { - e.Cancel = true; - FeedbackService.PlaySound(SystemSounds.Asterisk); - } - } - /// /// 在右鍵選單與片語子選單內標記應由選單接手的輸入鍵,避免方向鍵與 Enter 被原焦點控制項吞掉。 /// @@ -2723,4 +2819,4 @@ private void RefreshPhraseSubMenuAndSelectFirst(PhraseMenuSelectionTarget select } }); } -} \ No newline at end of file +} diff --git a/src/InputBox/MainForm.Events.cs b/src/InputBox/MainForm.Events.cs index cf394e7..901420f 100644 --- a/src/InputBox/MainForm.Events.cs +++ b/src/InputBox/MainForm.Events.cs @@ -665,8 +665,6 @@ private async Task HandleCopySuccessAsync(string textToCopy) { _historyService.Add(textToCopy); - bool shouldRestrictHighRiskShortcuts = SystemHelper.ShouldRestrictHighRiskShortcuts(); - FeedbackService.PlaySound(SystemSounds.Asterisk); await VibrateAsync(VibrationPatterns.CopySuccess); @@ -679,21 +677,11 @@ private async Task HandleCopySuccessAsync(string textToCopy) BtnCopy.Text = Strings.Msg_Copied; BtnCopy.AccessibleDescription = Strings.Msg_Copied; - if (shouldRestrictHighRiskShortcuts) - { - AnnounceA11y(Strings.Msg_Copied); - } - else - { - AnnounceA11y($"{Strings.Msg_Copied}. {Strings.A11y_Returning}"); - } + AnnounceA11y($"{Strings.Msg_Copied}. {Strings.A11y_Returning}"); TBInput.Clear(); - if (!shouldRestrictHighRiskShortcuts) - { - await ReturnToPreviousWindowAsync(announce: false); - } + await ReturnToPreviousWindowAsync(announce: false); if (IsDisposed || BtnCopy == null) @@ -760,16 +748,6 @@ private bool TryHandleHelpKeyDown(KeyEventArgs e) e.SuppressKeyPress = true; - // Gamescope (遊戲模式) 防護: - // 攔截 F1 快捷鍵呼叫說明對話框,避免破壞遊戲模式下的單一渲染表面。 - if (SystemHelper.IsRunningOnGamescope()) - { - LoggerService.LogInfo("[Gamescope Intercept] Help (F1) intercepted."); - FeedbackService.PlaySound(SystemSounds.Asterisk); - - return true; - } - ShowHelpDialog(); return true; @@ -1165,15 +1143,8 @@ private void ShowTouchKeyboard() // A11y 廣播。 AnnounceA11y(Strings.A11y_Opening_Keyboard); - // 若執行於 Wine (Proton) 或 Gamescope,改用 Steam URI scheme 喚起 Steam 螢幕鍵盤。 - // Gamescope 環境雖通常同時是 Wine,但以明確條件確保不因偵測差異而漏判。 - if ((SystemHelper.IsRunningOnWine() || SystemHelper.IsRunningOnGamescope()) && - TouchKeyboardService.TryOpenSteamKeyboard()) - { - return; - } - // 使用非同步延遲,避免與系統原生的 Focus 彈出行為發生競態。 + // Wine / Gamescope 的 Steam URI scheme 分支由 TouchKeyboardService.TryOpen() 統一處理。 // 快照 CancellationToken,避免多次讀取 _formCts 的競態條件。 CancellationToken ct = _formCts?.Token ?? CancellationToken.None; OpenTouchKeyboardWithDelayAsync(ct).SafeFireAndForget(); @@ -2090,4 +2061,4 @@ private void ShowGamepadCalibrationDialog() Debug.WriteLine($"[控制器] ShowGamepadCalibrationDialog 失敗:{ex.Message}"); } } -} \ No newline at end of file +} diff --git a/src/InputBox/MainForm.Gamepad.cs b/src/InputBox/MainForm.Gamepad.cs index 833c9d9..800422f 100644 --- a/src/InputBox/MainForm.Gamepad.cs +++ b/src/InputBox/MainForm.Gamepad.cs @@ -318,6 +318,18 @@ private void ApplyCurrentGamepadFaceButtonMode(bool announceProfileChange = fals /// 被按下的實體按鍵方位。 private void HandleFaceButtonAction(IGamepadController controller, GamepadFacePhysicalButton physicalButton) { + if (physicalButton == GamepadFacePhysicalButton.North && + controller.IsLeftShoulderHeld && + controller.IsRightShoulderHeld && + SystemHelper.IsRunningOnGamescope()) + { + _shoulderShortcutArbiter.ReserveDualShoulderCombo(); + TryPlayDualShoulderComboCue(); + RecoverGamescopeMainSurfaceFromGamepad(); + + return; + } + // Back 修飾鍵寬容視窗:IsBackHeld 為 true,或 Back 在 GamepadModifierGraceDelayMs 內曾被按下 // (處理 A/Y 比 Back 早一個輪詢 frame 被偵測到的時序偏差)。 bool isBackActiveOrRecent = @@ -890,11 +902,6 @@ private void HandleBackReleasedAction() return; } - if (SystemHelper.ShouldRestrictHighRiskShortcuts()) - { - return; - } - // 如果在按住 Back 期間使用了組合鍵(如 Back + Up),放開時就不觸發返回。 if (_isBackUsedAsModifier) { @@ -1067,12 +1074,6 @@ private void HandleVerticalGamepadInput( // 組合鍵(含連發):Back + Up/Down 調整透明度。 if (controller.IsBackHeld) { - if (SystemHelper.ShouldRestrictHighRiskShortcuts()) - { - _isBackUsedAsModifier = true; - return; - } - _isBackUsedAsModifier = true; TryPlayBackModifierCue(); @@ -1256,14 +1257,6 @@ private void HandleRightTriggerAction() /// 是否允許先暫存單鍵捷徑,以便雙板機組合優先攔截。 private void HandleTriggerShortcut(bool moveToEnd, bool allowDelayedShortcut) { - if (SystemHelper.ShouldRestrictSteamKeyboardTriggerShortcuts()) - { - CancelPendingTriggerShortcuts(); - Interlocked.Exchange(ref _privacyTriggerComboLatched, 0); - Interlocked.Exchange(ref _triggerComboCueLatched, 0); - return; - } - if (HandleContextMenuGamepadInput(moveToEnd ? "PhrasePageLast" : "PhrasePageFirst") || _cmsInput?.Visible == true || IsGamepadInputSuppressed()) @@ -1325,12 +1318,6 @@ private bool TryTogglePrivacyModeFromTriggerCombo() return false; } - if (SystemHelper.ShouldRestrictHighRiskShortcuts()) - { - CancelPendingTriggerShortcuts(); - Interlocked.Exchange(ref _privacyTriggerComboLatched, 1); - return true; - } if (Interlocked.Exchange(ref _privacyTriggerComboLatched, 1) != 0) { @@ -1961,11 +1948,6 @@ private void HandleBButtonAction(IGamepadController controller) { _shoulderShortcutArbiter.ReserveDualShoulderCombo(); - if (SystemHelper.ShouldRestrictHighRiskShortcuts()) - { - return; - } - TryPlayDualShoulderComboCue(); if (IsGamepadReturnSuppressed()) @@ -2015,12 +1997,6 @@ private void HandleXButtonAction(IGamepadController controller) // 組合鍵:Back + X 重設透明度(100%)。 if (controller.IsBackHeld) { - if (SystemHelper.ShouldRestrictHighRiskShortcuts()) - { - _isBackUsedAsModifier = true; - return; - } - _isBackUsedAsModifier = true; TryPlayBackModifierCue(); @@ -2145,22 +2121,33 @@ private void ClearInputByBButton() /// private void OpenContextMenuFromGamepadIfAllowed() { - if (SystemHelper.ShouldRestrictHighRiskShortcuts() || - IsGamepadInputSuppressed() || - _cmsInput == null || - _cmsInput.Visible) + if (IsGamepadInputSuppressed() || + !TryShowContextMenuAtInput()) { return; } - // 在文字方塊下方開啟選單。 - _cmsInput.Show(this, new Point(TBInput.Left, TBInput.Bottom)); - - SelectFirstVisibleMenuItemAndAnnounce(_cmsInput); + SelectFirstVisibleMenuItemAndAnnounce(_cmsInput!); VibrateNavigationAsync(VibrationSemantic.CursorMove, 1).SafeFireAndForget(); } + /// + /// Gamescope 專用控制器救援:不重建目前 popup HWND,只重建 MainForm surface。 + /// + private void RecoverGamescopeMainSurfaceFromGamepad() + { + if (!SystemHelper.IsRunningOnGamescope()) + { + return; + } + + CloseContextMenuForSurfaceRecovery(); + RecoverGamescopeMainSurface(); + + VibrateNavigationAsync(VibrationSemantic.ModeToggle, 1).SafeFireAndForget(); + } + /// /// 處理右搖桿文字選取輸入 /// @@ -2183,8 +2170,7 @@ private void HandleLSClickAction() /// private void HandleRSClickAction() { - if (SystemHelper.ShouldRestrictHighRiskShortcuts() || - ShouldSkipGamepadAction("RSClick") || + if (ShouldSkipGamepadAction("RSClick") || TBInput == null || TBInput.IsDisposed) { diff --git a/src/InputBox/MainForm.cs b/src/InputBox/MainForm.cs index 5f1b66d..871f37f 100644 --- a/src/InputBox/MainForm.cs +++ b/src/InputBox/MainForm.cs @@ -870,8 +870,7 @@ private bool HandleGlobalCmdKey(Keys keyData) onShowContextMenu: () => this.SafeInvoke(ShowContextMenuAtInput), canFocusInput: () => TBInput.CanFocus, onFocusInput: () => TBInput.Focus(), - onAnnounceSkipNav: () => AnnounceA11y(Strings.A11y_SkipNav_JumpToInput), - restrictHighRiskShortcuts: SystemHelper.ShouldRestrictHighRiskShortcuts())) + onAnnounceSkipNav: () => AnnounceA11y(Strings.A11y_SkipNav_JumpToInput))) { return false; } @@ -1314,4 +1313,4 @@ public static void DisposeCaches() { FontResourceManager.DisposeCaches(); } -} \ No newline at end of file +} diff --git a/src/InputBox/Program.cs b/src/InputBox/Program.cs index e4024cb..a6fc203 100644 --- a/src/InputBox/Program.cs +++ b/src/InputBox/Program.cs @@ -54,10 +54,6 @@ internal static class Program [STAThread] static void Main() { - // 在 Wine / Proton 環境下,將宿主 locale 橋接為 .NET CultureInfo, - // 確保衛星資源組件(zh-Hant、zh-Hans 等)能在啟動時正確載入。 - WineLocaleBootstrapper.Apply(); - try { // 使用 Mutex 確保單一執行個體。 @@ -595,4 +591,4 @@ static void HandleException(Exception? ex) // 發生嚴重錯誤後強制結束進程。 Environment.Exit(1); } -} \ No newline at end of file +} diff --git a/src/InputBox/Resources/Strings.Designer.cs b/src/InputBox/Resources/Strings.Designer.cs index 0adaad6..2ede408 100644 --- a/src/InputBox/Resources/Strings.Designer.cs +++ b/src/InputBox/Resources/Strings.Designer.cs @@ -1836,6 +1836,24 @@ internal static string Menu_PrivacyMode_Desc { } } + /// + /// 查詢類似 Recover Gamescope Display 的當地語系化字串。 + /// + internal static string Menu_RecoverGamescopeSurface { + get { + return ResourceManager.GetString("Menu_RecoverGamescopeSurface", resourceCulture); + } + } + + /// + /// 查詢類似 Recover the main InputBox display when Gamescope shows a black screen. 的當地語系化字串。 + /// + internal static string Menu_RecoverGamescopeSurface_Desc { + get { + return ResourceManager.GetString("Menu_RecoverGamescopeSurface_Desc", resourceCulture); + } + } + /// /// 查詢類似 Settings 的當地語系化字串。 /// @@ -1900,7 +1918,7 @@ internal static string Menu_Settings_Vibration { } /// - /// 查詢類似 Window & Operations 的當地語系化字串。 + /// 查詢類似 Window Operations 的當地語系化字串。 /// internal static string Menu_Settings_Window { get { diff --git a/src/InputBox/Resources/Strings.de.resx b/src/InputBox/Resources/Strings.de.resx index abf8020..c668c61 100644 --- a/src/InputBox/Resources/Strings.de.resx +++ b/src/InputBox/Resources/Strings.de.resx @@ -471,6 +471,14 @@ Stellen Sie sicher, dass nicht mehrere Instanzen dieser Anwendung gleichzeitig a Anpassen ... Klicken Sie mit der rechten Maustaste auf den Menüpunkt, um das Dialogfeld zur Anpassung der Deckkraft zu öffnen (ein Unterpunkt des Untermenüs „Deckkraft“). + + Gamescope-Anzeige wiederherstellen + Nur unter Gamescope sichtbarer Kontextmenüeintrag, der die Oberfläche des Hauptfensters neu erstellt, wenn die Anzeige schwarz wird. + + + Stellt die Hauptanzeige von InputBox wieder her, wenn Gamescope einen schwarzen Bildschirm zeigt. + Barrierefreiheitsbeschreibung für den Kontextmenüeintrag zur Wiederherstellung der Gamescope-Anzeige. + Einstellungsordner öffnen Kontextmenüeintrag zum Öffnen des Ordners mit den App-Einstellungen in AppData Roaming. diff --git a/src/InputBox/Resources/Strings.fr.resx b/src/InputBox/Resources/Strings.fr.resx index 2f278ac..cd40092 100644 --- a/src/InputBox/Resources/Strings.fr.resx +++ b/src/InputBox/Resources/Strings.fr.resx @@ -471,6 +471,14 @@ Vérifiez que plusieurs instances de cette application ne s'exécutent pas en m Ajuster… Élément de menu contextuel utilisé pour ouvrir la boîte de dialogue de réglage de l'opacité (un sous-élément du sous-menu d'opacité). + + Restaurer l’affichage Gamescope + Élément du menu contextuel réservé à Gamescope qui recrée la surface de la fenêtre principale lorsque l’affichage devient noir. + + + Restaure l’affichage principal d’InputBox lorsque Gamescope affiche un écran noir. + Description d’accessibilité de l’élément de menu de restauration de l’affichage Gamescope. + Ouvrir le dossier des paramètres Élément du menu contextuel permettant d'ouvrir le dossier des paramètres de l'application dans AppData Roaming. diff --git a/src/InputBox/Resources/Strings.ja.resx b/src/InputBox/Resources/Strings.ja.resx index c1996c9..0b0b1d8 100644 --- a/src/InputBox/Resources/Strings.ja.resx +++ b/src/InputBox/Resources/Strings.ja.resx @@ -471,6 +471,14 @@ 数値を設定… 右クリックメニュー項目。不透明度調整ダイアログを開くために使用します(不透明度サブメニューの子項目)。 + + Gamescope の画面を復旧 + メイン ウィンドウが黒画面になったときにメイン ウィンドウのサーフェスを再作成する、Gamescope 専用の右クリック メニュー項目です。 + + + Gamescope で黒画面が表示されたときに、InputBox のメイン画面を復旧します。 + Gamescope 画面復旧メニュー項目のアクセシビリティ説明です。 + 設定フォルダーを開く 右クリック メニュー項目です。AppData Roaming 内のアプリ設定フォルダーを開きます。 diff --git a/src/InputBox/Resources/Strings.ko.resx b/src/InputBox/Resources/Strings.ko.resx index 022970b..4e84153 100644 --- a/src/InputBox/Resources/Strings.ko.resx +++ b/src/InputBox/Resources/Strings.ko.resx @@ -471,6 +471,14 @@ 조정... 불투명도 조정 대화 상자를 열기 위한 상황에 맞는 메뉴 항목입니다(불투명도 하위 메뉴의 항목). + + Gamescope 화면 복구 + 기본 창 화면이 검은 화면으로 바뀌었을 때 기본 창 표면을 다시 만드는 Gamescope 전용 오른쪽 클릭 메뉴 항목입니다. + + + Gamescope에서 검은 화면이 표시될 때 InputBox 기본 화면을 복구합니다. + Gamescope 화면 복구 오른쪽 클릭 메뉴 항목의 접근성 설명입니다. + 설정 폴더 열기 AppData Roaming에 있는 앱 설정 폴더를 여는 바로 가기 메뉴 항목입니다. @@ -1358,4 +1366,4 @@ Back / View + ↑ / ↓ 창 불투명도 조정 (±5%) Nintendo Nintendo 스타일 페이스 버튼 배열 모드 이름입니다. - \ No newline at end of file + diff --git a/src/InputBox/Resources/Strings.resx b/src/InputBox/Resources/Strings.resx index ccdfe1d..491fbf8 100644 --- a/src/InputBox/Resources/Strings.resx +++ b/src/InputBox/Resources/Strings.resx @@ -440,7 +440,7 @@ Make sure that multiple instances of this application are not running at the sam Submenu name containing various advanced settings. - Window & Operations + Window Operations Context menu item for window and operation related settings. @@ -471,6 +471,14 @@ Make sure that multiple instances of this application are not running at the sam Adjust… Context menu item to open the opacity adjustment dialog (child item of the Opacity submenu). + + Recover Gamescope Display + Gamescope-only context menu item that rebuilds the main window surface when the display turns black. + + + Recover the main InputBox display when Gamescope shows a black screen. + Accessibility description for the Gamescope display recovery context menu item. + Open Settings Folder Context menu item for opening the app settings folder in AppData Roaming. @@ -1358,4 +1366,4 @@ Back / View + ↑ / ↓ Adjust window opacity (±5%) Nintendo Face-button layout mode label for Nintendo-style face-button labeling. - \ No newline at end of file + diff --git a/src/InputBox/Resources/Strings.zh-Hans.resx b/src/InputBox/Resources/Strings.zh-Hans.resx index 53a014f..6409814 100644 --- a/src/InputBox/Resources/Strings.zh-Hans.resx +++ b/src/InputBox/Resources/Strings.zh-Hans.resx @@ -471,6 +471,14 @@ 设置数值… 右键菜单项目,用于打开不透明度调整对话框(不透明度子菜单的子项目)。 + + 恢复 Gamescope 画面 + Gamescope 专用右键菜单项目,当主窗口画面变成黑屏时重建主窗口表面。 + + + 当 Gamescope 显示黑屏时,恢复 InputBox 主窗口画面。 + Gamescope 画面恢复右键菜单项目的无障碍描述。 + 打开设置文件夹 右键菜单项,用于打开应用在 AppData Roaming 中的设置文件夹。 @@ -1358,4 +1366,4 @@ Back/View + ↑ / ↓ 调整窗口透明度(±5%) Nintendo 控制器主按键配置模式名称:采用 Nintendo 风格的按键标示。 - \ No newline at end of file + diff --git a/src/InputBox/Resources/Strings.zh-Hant.resx b/src/InputBox/Resources/Strings.zh-Hant.resx index 052fd66..3b88bf8 100644 --- a/src/InputBox/Resources/Strings.zh-Hant.resx +++ b/src/InputBox/Resources/Strings.zh-Hant.resx @@ -471,6 +471,14 @@ 設定數值… 右鍵選單項目,用於開啟不透明度調整對話框(不透明度子選單的子項目)。 + + 恢復 Gamescope 畫面 + Gamescope 專用右鍵選單項目,當主視窗畫面變成黑畫面時重建主視窗表面。 + + + 當 Gamescope 顯示黑畫面時,恢復 InputBox 主視窗畫面。 + Gamescope 畫面恢復右鍵選單項目的無障礙描述。 + 開啟設定資料夾 右鍵選單項目,用於開啟應用程式在 AppData Roaming 中的設定資料夾。 @@ -1358,4 +1366,4 @@ Back/View + ↑/↓ 調整視窗透明度(±5%) Nintendo 控制器主按鍵配置模式名稱:採用 Nintendo 風格的按鍵標示。 - \ No newline at end of file + diff --git a/tests/InputBox.Tests/CmdKeyDispatcherTests.cs b/tests/InputBox.Tests/CmdKeyDispatcherTests.cs index c0684ae..3ec297c 100644 --- a/tests/InputBox.Tests/CmdKeyDispatcherTests.cs +++ b/tests/InputBox.Tests/CmdKeyDispatcherTests.cs @@ -70,4 +70,73 @@ public void TryGetContextMenuAction_SpecialKeys_MapToExpectedActions() Assert.True(CmdKeyDispatcher.TryGetContextMenuAction(Keys.End, true, out string? lastPageAction)); Assert.Equal("PhrasePageLast", lastPageAction); } + + /// + /// 全域返回快捷鍵應正確分派到返回處理器,實際是否允許返回由共用返回流程決定。 + /// + [Fact] + public void TryHandleGlobal_ReturnShortcut_DispatchesToReturnHandler() + { + int returnCalls = 0; + + bool handled = CmdKeyDispatcher.TryHandleGlobal( + Keys.Alt | Keys.B, + () => returnCalls++, + _ => throw new NotSupportedException(), + () => throw new NotSupportedException(), + () => throw new NotSupportedException(), + () => throw new NotSupportedException(), + static () => false, + static () => { }, + static () => { }); + + Assert.True(handled); + Assert.Equal(1, returnCalls); + } + + /// + /// 右鍵選單捷徑應保留分派,是否真的顯示交由選單建立點自行判斷。 + /// + [Fact] + public void TryHandleGlobal_ContextMenuShortcut_DispatchesToContextMenuHandler() + { + int contextMenuCalls = 0; + + bool handled = CmdKeyDispatcher.TryHandleGlobal( + Keys.F10, + () => throw new NotSupportedException(), + _ => throw new NotSupportedException(), + () => throw new NotSupportedException(), + () => throw new NotSupportedException(), + () => contextMenuCalls++, + static () => false, + static () => { }, + static () => { }); + + Assert.True(handled); + Assert.Equal(1, contextMenuCalls); + } + + /// + /// 隱私模式快捷鍵應保留分派,不應被全域命令分派器吞掉。 + /// + [Fact] + public void TryHandleGlobal_PrivacyShortcut_DispatchesToPrivacyHandler() + { + int privacyCalls = 0; + + bool handled = CmdKeyDispatcher.TryHandleGlobal( + Keys.Alt | Keys.P, + () => throw new NotSupportedException(), + _ => throw new NotSupportedException(), + () => throw new NotSupportedException(), + () => privacyCalls++, + () => throw new NotSupportedException(), + static () => false, + static () => { }, + static () => { }); + + Assert.True(handled); + Assert.Equal(1, privacyCalls); + } } diff --git a/tests/InputBox.Tests/README.md b/tests/InputBox.Tests/README.md index a1b2c34..7024931 100644 --- a/tests/InputBox.Tests/README.md +++ b/tests/InputBox.Tests/README.md @@ -13,7 +13,7 @@ |---|---|---| | `AnnouncementServiceTests` | `AnnouncementService` 訊息排隊、Dispose 行為與關閉時背景工作退出保護,以及 `AnnouncerLabel` 必要 A11y 預設值回歸保護 | 6 | | `AppSettingsTests` | `AppSettings` 關鍵常數、Clamp 行為、遊戲控制器調校快照,以及設定檔實際保存/讀回、併發保存、暫存清理、外來暫存檔保留與併發暫存檔誤刪回歸保護 | 52 | -| `CmdKeyDispatcherTests` | `CmdKeyDispatcher` 對右鍵選單混合輸入的鍵盤命令轉譯與原生確認鍵行為回歸保護 | 4 | +| `CmdKeyDispatcherTests` | `CmdKeyDispatcher` 對右鍵選單混合輸入的鍵盤命令轉譯與全域快捷鍵分派回歸保護 | 7 | | `DialogLayoutHelperTests` | `DialogLayoutHelper` 對話框版面輔助方法 | 9 | | `DialogLabelStabilityTests` | 片語管理/編輯對話框的動態計數標籤固定寬度、完整數字可視與零抖動回歸保護 | 3 | | `PhraseManagerDialogGamepadTests` | `PhraseManagerDialog` 左側片語清單的 LB/RB/LT/RT 快速切換、邊界跳轉、焦點接手與非必要連發抑制回歸保護 | 3 | @@ -39,11 +39,11 @@ | `RestartActivationCoordinatorTests` | `RestartActivationCoordinator` 的一次性重啟前景啟用標記、單次消費與過期清理保護 | 3 | | `RestartPromptStateTests` | 需重啟設定的待處理狀態追蹤、標題列提示,以及右鍵選單依 App 設定/系統變更/兩者同時存在而動態切換文案的回歸保護 | 7 | | `RestartRequestDeciderTests` | 手動重啟與設定變更兩種入口的確認策略回歸保護 | 3 | +| `SystemHelperTests` | `SystemHelper.EvaluateGamescopeEnvironment` 在不同 DISPLAY、XDG_CURRENT_DESKTOP 與 DESKTOP_SESSION 環境變數組合下的 Gamescope 環境偵測回歸保護 | 11 | | `TaskExtensionsTests` | `TaskExtensions` CTS 擴充方法與生命週期連結保護 | 12 | | `VibrationPatternsTests` | `VibrationPatterns` 與方向性震動設定、語意情境解析、能力感知的多段式微震動序列,以及歷程滾輪阻尼感、字數上限硬牆、震動強度預覽、右搖桿選取粒度、組合鍵進入提示與喚起握手回饋的回歸保護 | 37 | | `VibrationSafetyLimiterTests` | `VibrationSafetyLimiter` 熱保護、Duty Cycle 限制器與極端邊界保護 | 8 | -| `WineLocaleBootstrapperTests` | `WineLocaleBootstrapper` 的 POSIX locale 正規化(編碼後綴、@ 修飾詞去除、底線轉連字號)、zh 2 元件映射(TW/HK/MO → zh-Hant、CN/SG/MY → zh-Hans)、zh 3 元件 ICU 映射(zh-Hant-TW/HK/MO/MY、zh-Hans-CN/HK/MO/SG/MY)與無效輸入回傳 null 保護 | 34 | -| **合計** | | **384** | +| **合計** | | **364** | ## 二、執行方式 🚀 diff --git a/tests/InputBox.Tests/TaskExtensionsTests.cs b/tests/InputBox.Tests/TaskExtensionsTests.cs index 3527b7e..85350eb 100644 --- a/tests/InputBox.Tests/TaskExtensionsTests.cs +++ b/tests/InputBox.Tests/TaskExtensionsTests.cs @@ -157,16 +157,21 @@ public async Task SafeFireAndForget_CancelledTask_DoesNotInvokeOnException() public async Task SafeFireAndForget_FaultedTask_InvokesOnException() { Exception? captured = null; + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); Task faultedTask = Task.Run( () => throw new InvalidOperationException("test-error"), TestContext.Current.CancellationToken); faultedTask.SafeFireAndForget( - onException: ex => captured = ex); - - // 等待背景任務完成處理 - await Task.Delay(300, TestContext.Current.CancellationToken); + onException: ex => + { + captured = ex; + tcs.TrySetResult(); + }); + + // 等待回呼被觸發,最多 5 秒,不依賴固定計時 + await tcs.Task.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); Assert.NotNull(captured); Assert.IsType(captured); diff --git a/tests/InputBox.Tests/WineLocaleBootstrapperTests.cs b/tests/InputBox.Tests/WineLocaleBootstrapperTests.cs deleted file mode 100644 index 7a872e9..0000000 --- a/tests/InputBox.Tests/WineLocaleBootstrapperTests.cs +++ /dev/null @@ -1,376 +0,0 @@ -using InputBox.Core.Utilities; -using Xunit; - -namespace InputBox.Tests; - -/// -/// 的 POSIX locale 正規化與 culture 映射邏輯單元測試。 -/// -/// -/// Wine 偵測(IsRunningOnWine)與 實際套用屬於整合行為, -/// 無法在原生 Windows CI 環境中可靠驗證,故本套件僅覆蓋可純函數測試的邏輯層。 -/// -public sealed class WineLocaleBootstrapperTests -{ - // ── NormalizePosixToTag:繁體中文 2 組件形式 ───────────────────────────── - - /// - /// zh_TW.UTF-8 應正規化後映射為 zh-Hant(台灣繁體)。 - /// - [Fact] - public void NormalizePosixToTag_ZhTwUtf8_ReturnsZhHant() - { - Assert.Equal("zh-Hant", WineLocaleBootstrapper.NormalizePosixToTag("zh_TW.UTF-8")); - } - - /// - /// zh_HK.UTF-8 應映射為 zh-Hant(香港繁體,依 Linux glibc 慣例)。 - /// - [Fact] - public void NormalizePosixToTag_ZhHkUtf8_ReturnsZhHant() - { - Assert.Equal("zh-Hant", WineLocaleBootstrapper.NormalizePosixToTag("zh_HK.UTF-8")); - } - - /// - /// zh_MO.UTF-8 應映射為 zh-Hant(澳門繁體,依 Linux glibc 慣例)。 - /// - [Fact] - public void NormalizePosixToTag_ZhMoUtf8_ReturnsZhHant() - { - Assert.Equal("zh-Hant", WineLocaleBootstrapper.NormalizePosixToTag("zh_MO.UTF-8")); - } - - // ── NormalizePosixToTag:簡體中文 2 組件形式 ───────────────────────────── - - /// - /// zh_CN.UTF-8 應正規化後映射為 zh-Hans(中國大陸簡體)。 - /// - [Fact] - public void NormalizePosixToTag_ZhCnUtf8_ReturnsZhHans() - { - Assert.Equal("zh-Hans", WineLocaleBootstrapper.NormalizePosixToTag("zh_CN.UTF-8")); - } - - /// - /// zh_SG.UTF-8 應映射為 zh-Hans(新加坡簡體)。 - /// - [Fact] - public void NormalizePosixToTag_ZhSgUtf8_ReturnsZhHans() - { - Assert.Equal("zh-Hans", WineLocaleBootstrapper.NormalizePosixToTag("zh_SG.UTF-8")); - } - - /// - /// zh_MY.UTF-8 應映射為 zh-Hans(馬來西亞;zh-MY parent 為中性 zh,須明確映射至 zh-Hans)。 - /// - [Fact] - public void NormalizePosixToTag_ZhMyUtf8_ReturnsZhHans() - { - Assert.Equal("zh-Hans", WineLocaleBootstrapper.NormalizePosixToTag("zh_MY.UTF-8")); - } - - // ── NormalizePosixToTag:其他語系 ──────────────────────────────────────── - - /// - /// ja_JP.UTF-8 應正規化為 ja-JP,不需特殊映射。 - /// - [Fact] - public void NormalizePosixToTag_JaJpUtf8_ReturnsJaJp() - { - Assert.Equal("ja-JP", WineLocaleBootstrapper.NormalizePosixToTag("ja_JP.UTF-8")); - } - - /// - /// de_DE.UTF-8 應正規化為 de-DE,不需特殊映射。 - /// - [Fact] - public void NormalizePosixToTag_DeDeUtf8_ReturnsDeDE() - { - Assert.Equal("de-DE", WineLocaleBootstrapper.NormalizePosixToTag("de_DE.UTF-8")); - } - - /// - /// fr_FR.UTF-8 應正規化為 fr-FR,不需特殊映射。 - /// - [Fact] - public void NormalizePosixToTag_FrFrUtf8_ReturnsFrFr() - { - Assert.Equal("fr-FR", WineLocaleBootstrapper.NormalizePosixToTag("fr_FR.UTF-8")); - } - - /// - /// ko_KR.UTF-8 應正規化為 ko-KR,不需特殊映射。 - /// - [Fact] - public void NormalizePosixToTag_KoKrUtf8_ReturnsKoKr() - { - Assert.Equal("ko-KR", WineLocaleBootstrapper.NormalizePosixToTag("ko_KR.UTF-8")); - } - - // ── NormalizePosixToTag:無效與中性輸入 ────────────────────────────────── - - /// - /// Steam 強制設定的 "C" 應回傳 null,不套用任何 culture。 - /// - [Fact] - public void NormalizePosixToTag_C_ReturnsNull() - { - Assert.Null(WineLocaleBootstrapper.NormalizePosixToTag("C")); - } - - /// - /// "POSIX" locale 應回傳 null,不套用任何 culture。 - /// - [Fact] - public void NormalizePosixToTag_POSIX_ReturnsNull() - { - Assert.Null(WineLocaleBootstrapper.NormalizePosixToTag("POSIX")); - } - - /// - /// 空字串應回傳 null。 - /// - [Fact] - public void NormalizePosixToTag_Empty_ReturnsNull() - { - Assert.Null(WineLocaleBootstrapper.NormalizePosixToTag("")); - } - - /// - /// 空白字串應回傳 null。 - /// - [Fact] - public void NormalizePosixToTag_Whitespace_ReturnsNull() - { - Assert.Null(WineLocaleBootstrapper.NormalizePosixToTag(" ")); - } - - /// - /// 帶有 @ 修飾詞的 locale 應去除修飾詞後正規化,例如 de_DE@euro → de-DE。 - /// - [Fact] - public void NormalizePosixToTag_WithAtModifier_StripsModifier() - { - Assert.Equal("de-DE", WineLocaleBootstrapper.NormalizePosixToTag("de_DE@euro")); - } - - /// - /// 同時有編碼後綴與修飾詞時兩者皆應去除,例如 de_DE.UTF-8@euro → de-DE。 - /// - [Fact] - public void NormalizePosixToTag_WithEncodingAndModifier_StripsBoth() - { - Assert.Equal("de-DE", WineLocaleBootstrapper.NormalizePosixToTag("de_DE.UTF-8@euro")); - } - - /// - /// 不含地區的純語系 locale(例如 ja)應直接回傳語系代碼。 - /// - [Fact] - public void NormalizePosixToTag_LanguageOnly_ReturnsLanguage() - { - Assert.Equal("ja", WineLocaleBootstrapper.NormalizePosixToTag("ja")); - } - - /// - /// 前後含空白字元的輸入應在修剪後正常正規化(驗證 Trim() 防禦邏輯)。 - /// - [Fact] - public void NormalizePosixToTag_LeadingTrailingWhitespace_TrimsAndMaps() - { - Assert.Equal("zh-Hant", WineLocaleBootstrapper.NormalizePosixToTag(" zh_TW.UTF-8 ")); - } - - /// - /// en_US.UTF-8 應正規化為 en-US,無需特殊映射(Steam Deck 最常見的宿主 locale)。 - /// - [Fact] - public void NormalizePosixToTag_EnUsUtf8_ReturnsEnUs() - { - Assert.Equal("en-US", WineLocaleBootstrapper.NormalizePosixToTag("en_US.UTF-8")); - } - - // ── MapToSupportedCulture:繁體中文 2 組件形式 ─────────────────────────── - - /// - /// zh-TW 應映射為 zh-Hant。 - /// - [Fact] - public void MapToSupportedCulture_ZhTW_ReturnsZhHant() - { - Assert.Equal("zh-Hant", WineLocaleBootstrapper.MapToSupportedCulture("zh-TW")); - } - - /// - /// zh-HK 應映射為 zh-Hant(依 Linux glibc 慣例)。 - /// - [Fact] - public void MapToSupportedCulture_ZhHK_ReturnsZhHant() - { - Assert.Equal("zh-Hant", WineLocaleBootstrapper.MapToSupportedCulture("zh-HK")); - } - - /// - /// zh-MO 應映射為 zh-Hant(依 Linux glibc 慣例)。 - /// - [Fact] - public void MapToSupportedCulture_ZhMO_ReturnsZhHant() - { - Assert.Equal("zh-Hant", WineLocaleBootstrapper.MapToSupportedCulture("zh-MO")); - } - - // ── MapToSupportedCulture:簡體中文 2 組件形式 ─────────────────────────── - - /// - /// zh-CN 應映射為 zh-Hans。 - /// - [Fact] - public void MapToSupportedCulture_ZhCN_ReturnsZhHans() - { - Assert.Equal("zh-Hans", WineLocaleBootstrapper.MapToSupportedCulture("zh-CN")); - } - - /// - /// zh-SG 應映射為 zh-Hans。 - /// - [Fact] - public void MapToSupportedCulture_ZhSG_ReturnsZhHans() - { - Assert.Equal("zh-Hans", WineLocaleBootstrapper.MapToSupportedCulture("zh-SG")); - } - - /// - /// zh-MY 應映射為 zh-Hans(馬來西亞;2 組件 parent 為中性 zh,須明確映射避免 fallback 落回 Invariant)。 - /// - [Fact] - public void MapToSupportedCulture_ZhMY_ReturnsZhHans() - { - Assert.Equal("zh-Hans", WineLocaleBootstrapper.MapToSupportedCulture("zh-MY")); - } - - // ── MapToSupportedCulture:繁體中文 3 組件 ICU 形式 ───────────────────── - - /// - /// zh-Hant-TW 應映射為 zh-Hant(.NET 10 SpecificCultures canonical 形式)。 - /// - [Fact] - public void MapToSupportedCulture_ZhHantTW_ReturnsZhHant() - { - Assert.Equal("zh-Hant", WineLocaleBootstrapper.MapToSupportedCulture("zh-Hant-TW")); - } - - /// - /// zh-Hant-HK 應映射為 zh-Hant(.NET 10 SpecificCultures canonical 形式)。 - /// - [Fact] - public void MapToSupportedCulture_ZhHantHK_ReturnsZhHant() - { - Assert.Equal("zh-Hant", WineLocaleBootstrapper.MapToSupportedCulture("zh-Hant-HK")); - } - - /// - /// zh-Hant-MO 應映射為 zh-Hant(.NET 10 SpecificCultures canonical 形式)。 - /// - [Fact] - public void MapToSupportedCulture_ZhHantMO_ReturnsZhHant() - { - Assert.Equal("zh-Hant", WineLocaleBootstrapper.MapToSupportedCulture("zh-Hant-MO")); - } - - /// - /// zh-Hant-MY 應映射為 zh-Hant(馬來西亞繁體,.NET 10 SpecificCultures canonical 形式)。 - /// - [Fact] - public void MapToSupportedCulture_ZhHantMY_ReturnsZhHant() - { - Assert.Equal("zh-Hant", WineLocaleBootstrapper.MapToSupportedCulture("zh-Hant-MY")); - } - - // ── MapToSupportedCulture:簡體中文 3 組件 ICU 形式 ───────────────────── - - /// - /// zh-Hans-CN 應映射為 zh-Hans(.NET 10 SpecificCultures canonical 形式)。 - /// - [Fact] - public void MapToSupportedCulture_ZhHansCN_ReturnsZhHans() - { - Assert.Equal("zh-Hans", WineLocaleBootstrapper.MapToSupportedCulture("zh-Hans-CN")); - } - - /// - /// zh-Hans-HK 應映射為 zh-Hans(香港簡體,.NET 10 SpecificCultures canonical 形式)。 - /// - [Fact] - public void MapToSupportedCulture_ZhHansHK_ReturnsZhHans() - { - Assert.Equal("zh-Hans", WineLocaleBootstrapper.MapToSupportedCulture("zh-Hans-HK")); - } - - /// - /// zh-Hans-MO 應映射為 zh-Hans(澳門簡體,.NET 10 SpecificCultures canonical 形式)。 - /// - [Fact] - public void MapToSupportedCulture_ZhHansMO_ReturnsZhHans() - { - Assert.Equal("zh-Hans", WineLocaleBootstrapper.MapToSupportedCulture("zh-Hans-MO")); - } - - /// - /// zh-Hans-SG 應映射為 zh-Hans(.NET 10 SpecificCultures canonical 形式)。 - /// - [Fact] - public void MapToSupportedCulture_ZhHansSG_ReturnsZhHans() - { - Assert.Equal("zh-Hans", WineLocaleBootstrapper.MapToSupportedCulture("zh-Hans-SG")); - } - - /// - /// zh-Hans-MY 應映射為 zh-Hans(馬來西亞簡體,.NET 10 SpecificCultures canonical 形式)。 - /// - [Fact] - public void MapToSupportedCulture_ZhHansMY_ReturnsZhHans() - { - Assert.Equal("zh-Hans", WineLocaleBootstrapper.MapToSupportedCulture("zh-Hans-MY")); - } - - // ── MapToSupportedCulture:zh-Hant / zh-Hans 已正規化值 pass-through ──── - - /// - /// zh-Hant 已是目標值,應直接原樣回傳(不在 switch cases 中,走 _ => tag 路徑)。 - /// - [Fact] - public void MapToSupportedCulture_ZhHant_ReturnsZhHant() - { - Assert.Equal("zh-Hant", WineLocaleBootstrapper.MapToSupportedCulture("zh-Hant")); - } - - /// - /// zh-Hans 已是目標值,應直接原樣回傳(不在 switch cases 中,走 _ => tag 路徑)。 - /// - [Fact] - public void MapToSupportedCulture_ZhHans_ReturnsZhHans() - { - Assert.Equal("zh-Hans", WineLocaleBootstrapper.MapToSupportedCulture("zh-Hans")); - } - - // ── MapToSupportedCulture:非 zh 語系 pass-through ────────────────────── - - /// - /// 非 zh 的 culture tag(例如 ja-JP)應原樣回傳,交由 .NET 內建 fallback 處理衛星組件查找。 - /// - [Fact] - public void MapToSupportedCulture_NonZh_ReturnsOriginal() - { - Assert.Equal("ja-JP", WineLocaleBootstrapper.MapToSupportedCulture("ja-JP")); - } - - /// - /// de-DE 應原樣回傳,交由 .NET 內建 fallback chain 找到 de 衛星組件。 - /// - [Fact] - public void MapToSupportedCulture_DeDE_ReturnsDeDE() - { - Assert.Equal("de-DE", WineLocaleBootstrapper.MapToSupportedCulture("de-DE")); - } -}