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-Hant、zh-Hans 等)在啟動時即以正確語系載入。
- /// 讀取優先順序:LC_ALL → LANG。
- /// 若 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-DE、ja-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"));
- }
-}