diff --git a/.claude/skills/inputbox-dev/SKILL.md b/.claude/skills/inputbox-dev/SKILL.md index f85a5c8..50a997e 100644 --- a/.claude/skills/inputbox-dev/SKILL.md +++ b/.claude/skills/inputbox-dev/SKILL.md @@ -1,6 +1,6 @@ --- name: inputbox-dev -description: Claude Code 對 InputBox 工程技能的橋接。當修改 InputBox 程式碼、UI、控制器邏輯、在地化、測試或 Git 工作流時使用。 +description: 修改 InputBox 程式碼、UI、控制器邏輯、在地化、測試、工程規範或 Git 工作流時使用,負責載入對應的工程規範;一般只讀問答不需啟用。本檔為 Claude Code 橋接,權威內容在 .agents/skills/inputbox-dev/SKILL.md。 --- # InputBox Claude Code Skill Bridge diff --git a/.claude/skills/update-inputweave-gameinput/SKILL.md b/.claude/skills/update-inputweave-gameinput/SKILL.md index 5eba13e..329202a 100644 --- a/.claude/skills/update-inputweave-gameinput/SKILL.md +++ b/.claude/skills/update-inputweave-gameinput/SKILL.md @@ -1,6 +1,6 @@ --- name: update-inputweave-gameinput -description: Claude Code 對 InputWeave.GameInput 更新技能的橋接。更新 InputBox 內嵌套件、來源 commit、雜湊、授權、CI 或 gh-pages 第三方資訊時使用。 +description: 更新或重新封裝 InputBox 內嵌的 InputWeave.GameInput 套件,並同步來源 commit、SHA-256、授權、CI、release workflow 與 gh-pages 第三方資訊時使用。本檔為 Claude Code 橋接,權威流程在 .agents/skills/update-inputweave-gameinput/SKILL.md。 --- # InputWeave.GameInput Claude Code Skill Bridge diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json index ff974d2..87217e2 100644 --- a/.config/dotnet-tools.json +++ b/.config/dotnet-tools.json @@ -9,7 +9,7 @@ ] }, "nuget-license": { - "version": "4.0.17", + "version": "4.0.18", "commands": [ "nuget-license" ] diff --git a/.editorconfig b/.editorconfig index c69b4f6..8430fa2 100644 --- a/.editorconfig +++ b/.editorconfig @@ -99,9 +99,16 @@ dotnet_style_allow_statement_immediately_after_block_experimental = true #### C# 編碼慣例 #### # var 喜好設定 +# 依 Microsoft C# 編碼慣例與 .NET Runtime 程式碼風格: +# - 型別可由右側直接看出(new、明確轉型)時可使用 var,亦可使用 target-typed new()。 +# - 內建型別、方法回傳值等型別無法由右側直接看出時,一律使用明確型別;不以方法或變數名稱推測型別。 +# - foreach 迴圈變數使用明確型別;LINQ 匿名型別等必要情況除外。 csharp_style_var_elsewhere = false csharp_style_var_for_built_in_types = false -csharp_style_var_when_type_is_apparent = false +csharp_style_var_when_type_is_apparent = true +# IDE0008:型別無法直接看出卻使用 var 時提示改為明確型別;IDE0007(改用 var)不主動提示,保留既有明確型別寫法。 +dotnet_diagnostic.IDE0008.severity = suggestion +dotnet_diagnostic.IDE0007.severity = silent # 運算式主體成員 csharp_style_expression_bodied_accessors = true diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f3912ba..3015e10 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -115,7 +115,7 @@ jobs: Write-Error "InputWeave.GameInput 套件 SHA256 不符。Expected=$expected Actual=$actual" } - $expectedCommit = "a3985f29c19b35365124d70cfbf0c21d1596ad3e" + $expectedCommit = "89b148669ffe58d9927495ed0d89f9997bf4962a" $inspectDir = Join-Path $env:RUNNER_TEMP "inputweave-gameinput-package" if (Test-Path $inspectDir) { Remove-Item -LiteralPath $inspectDir -Recurse -Force diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7131cf4..0ba59d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -92,7 +92,7 @@ jobs: Write-Error "InputWeave.GameInput 套件 SHA256 不符。Expected=$expected Actual=$actual" } - $expectedCommit = "a3985f29c19b35365124d70cfbf0c21d1596ad3e" + $expectedCommit = "89b148669ffe58d9927495ed0d89f9997bf4962a" $inspectDir = Join-Path $env:RUNNER_TEMP "inputweave-gameinput-package" if (Test-Path $inspectDir) { Remove-Item -LiteralPath $inspectDir -Recurse -Force @@ -232,7 +232,8 @@ jobs: # -i:指定專案檔路徑。 # -o:指定輸出格式為 Markdown(讓純文字檔內有整齊的表格排版)。 # -fo:指定輸出的檔案名稱(File Output)。 - dotnet tool run nuget-license -i ./src/InputBox/InputBox.csproj -o Markdown -fo ThirdPartyNotices.txt -ignore Microsoft.GameInput + # -t:一併列出遞移相依套件(例如 InputWeave.GameInput 相依的 Microsoft.Extensions.DependencyInjection.Abstractions),避免隨附的函式庫缺少授權聲明。 + dotnet tool run nuget-license -i ./src/InputBox/InputBox.csproj -t -o Markdown -fo ThirdPartyNotices.txt -ignore Microsoft.GameInput if (Test-Path "ThirdPartyNotices.txt") { Write-Host "✅ 成功產生專案依賴套件的 ThirdPartyNotices.txt!" @@ -254,13 +255,13 @@ jobs: Add-Content -Path $noticePath -Encoding utf8 -Value '## InputWeave.GameInput' Add-Content -Path $noticePath -Encoding utf8 -Value '' Add-Content -Path $noticePath -Encoding utf8 -Value '- 套件:InputWeave.GameInput 0.0.1' - Add-Content -Path $noticePath -Encoding utf8 -Value '- 來源:https://github.com/rubujo/InputWeave.GameInput/tree/a3985f29c19b35365124d70cfbf0c21d1596ad3e' + Add-Content -Path $noticePath -Encoding utf8 -Value '- 來源:https://github.com/rubujo/InputWeave.GameInput/tree/89b148669ffe58d9927495ed0d89f9997bf4962a' Add-Content -Path $noticePath -Encoding utf8 -Value '- 作者:https://github.com/rubujo' Add-Content -Path $noticePath -Encoding utf8 -Value '- 貢獻者:https://github.com/rubujo/InputWeave.GameInput/graphs/contributors' - Add-Content -Path $noticePath -Encoding utf8 -Value '- 封裝來源:https://github.com/rubujo/InputWeave.GameInput/commit/a3985f29c19b35365124d70cfbf0c21d1596ad3e' + Add-Content -Path $noticePath -Encoding utf8 -Value '- 封裝來源:https://github.com/rubujo/InputWeave.GameInput/commit/89b148669ffe58d9927495ed0d89f9997bf4962a' Add-Content -Path $noticePath -Encoding utf8 -Value '- 套件檔案:eng/nuget/InputWeave.GameInput.0.0.1.nupkg' - Add-Content -Path $noticePath -Encoding utf8 -Value '- SHA256: 3e9a3d65861a9211380aec6211ccf9f6b9151eae20df6d8917d3e75a98b9b589' - Add-Content -Path $noticePath -Encoding utf8 -Value '- 授權:Creative Commons CC0 1.0 Universal(SPDX:CC0-1.0);詳見本 Licenses 目錄中的 InputWeave.GameInput_LICENSE.txt 與 https://github.com/rubujo/InputWeave.GameInput/blob/a3985f29c19b35365124d70cfbf0c21d1596ad3e/LICENSE。' + Add-Content -Path $noticePath -Encoding utf8 -Value '- SHA256: 3342051977f6f1b91f2a18acfc224456320264dd67633ed4bc41c8188c9fcdb7' + Add-Content -Path $noticePath -Encoding utf8 -Value '- 授權:Creative Commons CC0 1.0 Universal(SPDX:CC0-1.0);詳見本 Licenses 目錄中的 InputWeave.GameInput_LICENSE.txt 與 https://github.com/rubujo/InputWeave.GameInput/blob/89b148669ffe58d9927495ed0d89f9997bf4962a/LICENSE。' - name: 產生執行檔的雜湊值(SHA256) shell: pwsh diff --git a/README.md b/README.md index b3d7ed5..883246b 100644 --- a/README.md +++ b/README.md @@ -393,7 +393,7 @@ 若需使用控制器目前配置下的取消鍵關閉觸控式鍵盤,可將其「鍵盤配置」設定為「遊戲控制器」。 -### 2. 從 Xbox 全螢幕體驗切換回 Windows 桌面後,應用程式視窗過大 📺 +### 2. 從 Xbox 模式切換回 Windows 桌面後,應用程式視窗過大 📺 可手動縮放視窗大小(使用手指或滑鼠)。 @@ -592,7 +592,8 @@ Remove-Item Env:INPUTBOX_RUN_UI_TESTS -ErrorAction SilentlyContinue - [.NET Runtime](https://github.com/dotnet/runtime):由 [Microsoft](https://github.com/microsoft) 及其 [貢獻者](https://github.com/dotnet/runtime/graphs/contributors) 開發並採用 [**MIT License**](https://github.com/dotnet/runtime/blob/main/LICENSE.TXT) 授權,作為本應用程式之底層執行環境,相關第三方聲明請參閱 [**THIRD-PARTY-NOTICES**](https://github.com/dotnet/runtime/blob/main/THIRD-PARTY-NOTICES.TXT)。 - [Windows Forms(WinForms)](https://github.com/dotnet/winforms):由 [Microsoft](https://github.com/microsoft) 及其 [貢獻者](https://github.com/dotnet/winforms/graphs/contributors) 開發並採用 [**MIT License**](https://github.com/dotnet/winforms/blob/main/LICENSE.TXT) 授權,提供桌面視窗圖形介面基礎架構,相關第三方聲明請參閱 [**THIRD-PARTY-NOTICES**](https://github.com/dotnet/winforms/blob/main/THIRD-PARTY-NOTICES.TXT)。 -- [InputWeave.GameInput 0.0.1](https://github.com/rubujo/InputWeave.GameInput/tree/a3985f29c19b35365124d70cfbf0c21d1596ad3e):由 [rubujo](https://github.com/rubujo) 及其 [貢獻者](https://github.com/rubujo/InputWeave.GameInput/graphs/contributors) 開發並採用 [**CC0 1.0 Universal**](https://github.com/rubujo/InputWeave.GameInput/blob/a3985f29c19b35365124d70cfbf0c21d1596ad3e/LICENSE) 授權,提供 GameInput 執行階段、用戶端、裝置、讀取狀態與震動控制介面。套件固定於本儲存庫的 NuGet 來源 `eng/nuget/InputWeave.GameInput.0.0.1.nupkg`,封裝來源為 [`a3985f29c19b35365124d70cfbf0c21d1596ad3e`](https://github.com/rubujo/InputWeave.GameInput/commit/a3985f29c19b35365124d70cfbf0c21d1596ad3e),套件 SHA256 為 `3e9a3d65861a9211380aec6211ccf9f6b9151eae20df6d8917d3e75a98b9b589`。 -- [Microsoft GameInput Redistributable](https://www.nuget.org/packages/Microsoft.GameInput):由 Microsoft 提供,作為選用的 GameInput 執行階段可轉散發套件。正式發佈 ZIP 檔會隨附 `redist/GameInputRedist.msi` 供使用者手動安裝;本應用程式不會自動安裝。手動安裝 `redist/GameInputRedist.msi` 即表示使用者需遵守 [Microsoft.GameInput 授權條款](https://www.nuget.org/packages/Microsoft.GameInput/3.5.270/License);相關授權與 NOTICE 檔案位於 `Licenses/Microsoft.GameInput_LICENSE.txt` 與 `Licenses/Microsoft.GameInput_NOTICE.txt`,且該可轉散發安裝程式不屬於本專案 CC0 授權範圍。 +- [InputWeave.GameInput 0.0.1](https://github.com/rubujo/InputWeave.GameInput/tree/89b148669ffe58d9927495ed0d89f9997bf4962a):由 [rubujo](https://github.com/rubujo) 及其 [貢獻者](https://github.com/rubujo/InputWeave.GameInput/graphs/contributors) 開發並採用 [**CC0 1.0 Universal**](https://github.com/rubujo/InputWeave.GameInput/blob/89b148669ffe58d9927495ed0d89f9997bf4962a/LICENSE) 授權,提供 GameInput 執行階段、用戶端、裝置、讀取狀態與震動控制介面。套件固定於本儲存庫的 NuGet 來源 `eng/nuget/InputWeave.GameInput.0.0.1.nupkg`,封裝來源為 [`89b148669ffe58d9927495ed0d89f9997bf4962a`](https://github.com/rubujo/InputWeave.GameInput/commit/89b148669ffe58d9927495ed0d89f9997bf4962a),套件 SHA256 為 `3342051977f6f1b91f2a18acfc224456320264dd67633ed4bc41c8188c9fcdb7`。 +- [Microsoft.Extensions.DependencyInjection.Abstractions](https://www.nuget.org/packages/Microsoft.Extensions.DependencyInjection.Abstractions):由 [Microsoft](https://github.com/microsoft) 及其 [貢獻者](https://github.com/dotnet/runtime/graphs/contributors) 於 [.NET Runtime](https://github.com/dotnet/runtime) 儲存庫開發並採用 [**MIT License**](https://github.com/dotnet/runtime/blob/main/LICENSE.TXT) 授權,作為 InputWeave.GameInput 的相依套件,提供相依性插入(Dependency Injection)抽象介面。 +- [Microsoft GameInput Redistributable](https://www.nuget.org/packages/Microsoft.GameInput):由 Microsoft 提供,作為選用的 GameInput 執行階段可轉散發套件。正式發佈 ZIP 檔會隨附 `redist/GameInputRedist.msi` 供使用者手動安裝;本應用程式不會自動安裝。手動安裝 `redist/GameInputRedist.msi` 即表示使用者需遵守 [Microsoft.GameInput 授權條款](https://www.nuget.org/packages/Microsoft.GameInput/3.5.278/License);相關授權與 NOTICE 檔案位於 `Licenses/Microsoft.GameInput_LICENSE.txt` 與 `Licenses/Microsoft.GameInput_NOTICE.txt`,且該可轉散發安裝程式不屬於本專案 CC0 授權範圍。 -本專案的詳細條款與免責聲明,請參閱隨附之 [**LICENSE**](LICENSE) 文件;發佈檔 `Licenses/` 資料夾內含 `ThirdPartyNotices.txt`(NuGet 套件授權聲明清單)、`InputWeave.GameInput_LICENSE.txt`、`Microsoft.GameInput_LICENSE.txt`、`Microsoft.GameInput_NOTICE.txt`,以及各元件完整授權文字;[Microsoft.GameInput 3.5.270 線上授權頁](https://www.nuget.org/packages/Microsoft.GameInput/3.5.270/License) 另可於 NuGet 查閱。 +本專案的詳細條款與免責聲明,請參閱隨附之 [**LICENSE**](LICENSE) 文件;發佈檔 `Licenses/` 資料夾內含 `ThirdPartyNotices.txt`(NuGet 套件授權聲明清單)、`InputWeave.GameInput_LICENSE.txt`、`Microsoft.GameInput_LICENSE.txt`、`Microsoft.GameInput_NOTICE.txt`,以及各元件完整授權文字;[Microsoft.GameInput 3.5.278 線上授權頁](https://www.nuget.org/packages/Microsoft.GameInput/3.5.278/License) 另可於 NuGet 查閱。 diff --git a/docs/engineering/a11y-safety.md b/docs/engineering/a11y-safety.md index 7d0c19c..8a18854 100644 --- a/docs/engineering/a11y-safety.md +++ b/docs/engineering/a11y-safety.md @@ -38,6 +38,7 @@ - 深色中性(未反轉,深色主題)→ `LightBlue` (≥7.2:1 AAA) - 淺色中性(未反轉,淺色主題)→ `MediumBlue` (8.14:1 AAA) - **視覺脈衝 (Flash Alert)**: + - **唯一實作**:警示色、每幀配色與動畫節奏集中於 `FlashAlertAnimator`;各視窗只負責套用目標控制項與結束後的視覺還原,不得自行複製脈衝或配色邏輯。 - 頻率:1Hz 平滑正弦波脈衝。 - 基色:固定為焦點反轉底色 (深色用 White,淺色用 Black)。 - **動態 ForeColor 連動**:背景亮度 **L > 0.1791** 時切換文字顏色。 diff --git a/docs/engineering/agent-support.md b/docs/engineering/agent-support.md index cc399a6..e2c1475 100644 --- a/docs/engineering/agent-support.md +++ b/docs/engineering/agent-support.md @@ -25,7 +25,11 @@ ## 官方依據與查核日期 - OpenAI(2026-07-30):[`AGENTS.md` custom instructions](https://developers.openai.com/codex/guides/agents-md) 與 [Codex skills](https://developers.openai.com/codex/skills)。 -- Claude Code、GitHub Copilot CLI 與 Antigravity CLI 的既有支援策略最後查核於 2026-05-25;若修改對應橋接或支援聲明,須先以各供應商最新官方文件重新查核。 +- Anthropic(2026-09-27):[記憶與 `AGENTS.md`](https://code.claude.com/docs/en/memory) 與 [Skills](https://code.claude.com/docs/en/skills)。 + - Claude Code 可直接讀取 `AGENTS.md`,但只在工作目錄以上沒有 `CLAUDE.md`、`.claude/CLAUDE.md` 或 `CLAUDE.local.md` 時才會讀取;部分工作階段無法直接讀取。因此保留 `CLAUDE.md` 的 `@AGENTS.md` 匯入,Claude 專屬內容只放在匯入之後;官方確認此寫法不會重複載入。 + - Claude Code 只從 `.claude/skills/` 探索 project skill,不讀取 `.agents/skills/`,因此橋接 skill 仍屬必要。 + - Skill 遵循 [Agent Skills](https://agentskills.io) 開放標準;`description` 應先寫主要使用情境(與 `when_to_use` 合計約 1,536 字元後截斷)。跨工具共用的權威 skill 只使用標準欄位(`name`、`description`、`license`、`compatibility`、`metadata`、`allowed-tools`),Claude Code 專屬欄位只可放在 `.claude/skills/` 橋接檔。 +- GitHub Copilot CLI 與 Antigravity CLI 的既有支援策略最後查核於 2026-05-25;若修改對應橋接或支援聲明,須先以各供應商最新官方文件重新查核。 ## Context 維護原則 diff --git a/docs/engineering/core-engineering.md b/docs/engineering/core-engineering.md index d3cb46f..bc1d034 100644 --- a/docs/engineering/core-engineering.md +++ b/docs/engineering/core-engineering.md @@ -25,11 +25,14 @@ - **主題還原**:還原預設配色時應將屬性設為 `Color.Empty`,由 .NET 10 主題引擎自動判斷 (禁:硬式編碼 `SystemColors.Control`)。 - **程式內重啟與前景恢復**: - **單次啟用標記**:若為本程式主動要求重新啟動,舊執行個體必須先寫入具短時效的一次性啟用標記,供新執行個體在首次顯示時消費;標記必須於讀取後立即清除,避免重複生效。 - - **前景授權交接**:呼叫 `Application.Restart()` 前,應先透過 `AllowSetForegroundWindow(...)` 授權新執行個體搶回前景,降低焦點落回前一個視窗的機率。 + - **前景授權交接**:由 `RestartProcessLauncher` 自行啟動新執行個體(取代 `Application.Restart()`),並在舊視窗關閉前以 `AllowSetForegroundWindow(新程序 PID)` 只授權該程序搶回前景;不得使用 `ASFW_ANY` 開放給所有程序,僅在無法啟動新程序而退回 `Application.Restart()` 時例外。授權必須在舊視窗仍位於前景時完成,因為控制器操作不會產生 Windows 輸入事件。 + - **單一執行個體交接**:舊執行個體以 `--restart-handoff=<舊 PID>` 參數啟動新執行個體,**不得在啟動前釋放** 單一執行個體 Mutex,必須在關閉所有視窗後、`Environment.Exit` 之前於主執行緒明確釋放;新執行個體依 `SingleInstanceHandoff` 規則在主執行緒等待並接手 Mutex(被遺棄的 Mutex 亦視為接手成功),不得喚醒既有實例後退出。交接期間另外啟動的執行個體若喚醒失敗且偵測到重啟請求進行中,必須結束而不得 fallback 另開視窗。退回 `Application.Restart()` 時新執行個體不帶交接參數,才可先釋放 Mutex。 + - **全域快速鍵先解除**:新執行個體會在舊視窗關閉前啟動,舊執行個體必須先解除全域快速鍵,避免新執行個體接手後註冊失敗。 - **新執行個體恢復流程**:新視窗啟動後應先走較強的前景還原路徑(例如 `WindowFocusService.RestoreWindowAsync(...)`),再以有限次數重試 `ShowWindow`、`BringWindowToTop`、`SetForegroundWindow`、`Activate()` 與輸入框 `Focus()`;不可只做單次 `Activate()` 後就假設成功。 - **避免錯誤回切**:當重啟是由程式內設定變更觸發時,舊執行個體應避免再把「前一個外部視窗」覆寫為目前狀態,以免新執行個體恢復焦點時誤切回錯誤目標。 - **格式與診斷紀律**: - **EditorConfig 強制套用**:每次修改任何檔案後,必須遵循專案根目錄 `.editorconfig` 的縮排、編碼、換行與格式設定。 - **C# 診斷清零**:每次修改 `*.cs` 檔案後,提交或回覆前都必須檢查該檔案的 IDE 與 CS 類型診斷,並修正新增的建議、警告與錯誤。 + - **`var` 使用原則**:依 [Microsoft C# 編碼慣例](https://learn.microsoft.com/dotnet/csharp/fundamentals/coding-style/coding-conventions#implicitly-typed-local-variables) 與 [.NET Runtime 程式碼風格](https://github.com/dotnet/runtime/blob/main/docs/coding-guidelines/coding-style.md),僅在型別可由右側直接看出(`new`、明確轉型)時使用 `var`,此時亦可使用 target-typed `new()`;內建型別、方法回傳值與 `foreach` 迴圈變數一律使用明確型別,不以方法或變數名稱推測型別;LINQ 匿名型別等必須使用 `var` 的情況除外。對應設定見 `.editorconfig`(IDE0007/IDE0008)。 - **Release 日誌門檻**:正式版預設只寫入 `Warning` 以上;`Info` 僅供 Debug、測試主機或 `INPUTBOX_LOG_LEVEL=Info` 臨時診斷使用。 - **可行動診斷原則**:正常 lifecycle、成功 probe、成功或略過震動、一般輪詢健康資料應維持 `Info`;會影響使用者操作、裝置可用性、API 呼叫失敗或資料修復失敗的訊號才可升級為 `Warning` 或 `Error`。 diff --git a/docs/engineering/gamepad-api.md b/docs/engineering/gamepad-api.md index 816199d..be519a0 100644 --- a/docs/engineering/gamepad-api.md +++ b/docs/engineering/gamepad-api.md @@ -12,6 +12,9 @@ - **令牌隔離**:實作類別內部必須獨立實作 `_vibrationToken` (Interlocked),**禁止在服務層級共享**,以支援多控制器獨立運行的隔離性。 - **同步停止**:必須具備同步 `StopVibration()` 方法,支援緊急清理。 - **連結權杖 (Linked Token)**:震動延遲須結合外部取消權杖與內部覆寫權杖,確保視窗關閉時馬達能立即停止。 + - **熱成本正規化**:傳給 `VibrationSafetyLimiter` 的熱成本倍率必須透過 `GetMotorThermalCostMultiplier(馬達數)` 取得(以雙主馬達為 1.0),XInput 與 GameInput 共用同一基準;不得直接傳入馬達數量。 + - **回饋不得冷啟動遺失**:Normal 優先級單次請求超出剩餘熱預算時應先降低強度,只有低於優先級保底比例才可拒絕;新增或調整 `VibrationPatterns` 後,必須通過「所有內建模式冷啟動不被拒絕」測試。 + - **Critical 連發節流**:前一個 Critical 請求之後 500ms 內再出現的任何 Critical 請求(不論強度與時長是否相同),限制器都會視為自動連發並降為 Normal 交由熱保護節流,避免連按或按住時不受熱保護地連續以高強度驅動馬達;冷啟動時 Normal 仍完整送出,短序列不受影響。 - **Face 鍵語意路由 (Semantic Face-Button Routing)**: - 所有「確認/取消/刪除/選單」行為,必須透過集中化的配置描述(例如 `GamepadFaceButtonProfile`)解析,**禁止**在 Dialog、MessageBox 或主視窗中硬式編碼 `A=確認`、`B=取消`。 - 實作時應優先使用邏輯語意(例如 south/east/west/north 對應的功能)而非控制器字樣,避免 Nintendo 與 PlayStation 模式下產生顯示正確但功能錯置的回歸。 @@ -45,7 +48,7 @@ ### 4.1 演算法結構 -- **自適應 EMA**:學習率公式為 `α = base + (max − base) × clamp(|error| / BiasAdaptiveErrorRange, 0, 1)`,誤差越大學習率越高,越快追蹤到真實偏移。 +- **自適應 EMA**:學習率公式為 `α = base + (max − base) × clamp(|error| / BiasAdaptiveErrorRange, 0, 1)`,誤差越大學習率越高,越快追蹤到真實偏移。平滑係數與公式只在 `GamepadBiasSmoothing` 維護,兩個後端不得各自宣告副本,只能提供與自身數值尺度對應的 `BiasAdaptiveErrorRange`。 - **四軸追蹤**:必須對左搖桿 X/Y 與右搖桿 X/Y 共四軸分別維護 `_leftStickBiasX/Y`、`_rightStickBiasX/Y`。 - **右搖桿 Y 軸**:右搖桿 Y 偏移會被學習並**校正用於診斷**(`correctedRightThumbY` 出現於 Health Log 與 Ghost Log),但**不觸發任何導航事件**,亦不納入 `hasSignificantInput` 或 `ShouldForceReleaseDirectionalRepeat` 判斷。 @@ -58,7 +61,7 @@ XInput 以 `short` 範圍 (±32767) 運作;GameInput 以 float `[-1.0, 1.0]` |---|---|---| | `BiasAdaptiveErrorRange` | `1638f` (≈ 0.05 × 32767) | `0.05f` | | `LeftStickBiasLearningThreshold` | `9000` (≈ 0.275 × 32767) | `0.28f` | -| EMA 平滑係數 | 與 GameInput **完全相同** | 與 XInput **完全相同** | +| EMA 平滑係數與學習率公式 | 共用 `GamepadBiasSmoothing` | 共用 `GamepadBiasSmoothing` | ### 4.3 D-Pad 機械耦合防污閘門 diff --git a/docs/engineering/git-commit-safety.md b/docs/engineering/git-commit-safety.md index 6ca879f..8db9336 100644 --- a/docs/engineering/git-commit-safety.md +++ b/docs/engineering/git-commit-safety.md @@ -5,6 +5,11 @@ - **零模擬/同步**:行為僅止於「複製至剪貼簿」,不模擬輸入至其他視窗。 - **零自動化**:禁止實作自動化遊戲行為 (如自動連點、自動施法)。 - **零偵測性**:禁止主動偵測特定第三方應用程式。 +- **前景視窗返回的允許範圍**:`WindowFocusService` 只把焦點還給使用者呼叫 InputBox 前的前景視窗;目標由當下前景視窗捕捉,唯一的程序判斷是排除 InputBox 自己,不依程序名稱、視窗標題或類別辨識任何特定應用程式。 + - 允許的 Win32 呼叫僅限 `ShowWindow`(且只在視窗最小化時還原)、`BringWindowToTop`、`SetForegroundWindow`、`SetFocus`,以及切換瞬間暫時使用 `AttachThreadInput` 連結執行緒輸入佇列,並必須在 `finally` 中解除。 + - `AttachThreadInput` 只用於取得切換前景的權限,不送出任何按鍵、滑鼠或視窗訊息,也不讀寫對方程序的記憶體,因此不屬於模擬輸入或修改第三方程式行為。 + - 不得在此路徑加入 `SendInput`、`keybd_event`、`PostMessage`/`SendMessage` 輸入訊息、剪貼簿自動貼上或任何依應用程式身分分流的邏輯。 + - 程式內重啟只可對新啟動的 InputBox 程序呼叫 `AllowSetForegroundWindow(PID)`,不得使用 `ASFW_ANY` 開放給所有程序(無法啟動新程序的退回路徑除外)。 ## 2. 外部合規基準與 ToS 驗證 (ToS Verification) **代理人必須在變更任何核心輸入/輸出邏輯前,使用網頁抓取工具擷取並分析以下網址的最新內容(例如 Copilot:`fetch_webpage`;Gemini:`web_fetch`),確保設計不違反服務條款:** diff --git a/eng/nuget/InputWeave.GameInput.0.0.1.nupkg b/eng/nuget/InputWeave.GameInput.0.0.1.nupkg index 855252f..54f968d 100644 Binary files a/eng/nuget/InputWeave.GameInput.0.0.1.nupkg and b/eng/nuget/InputWeave.GameInput.0.0.1.nupkg differ diff --git a/eng/nuget/InputWeave.GameInput.0.0.1.nupkg.sha256 b/eng/nuget/InputWeave.GameInput.0.0.1.nupkg.sha256 index 9bf9889..6f58ea6 100644 --- a/eng/nuget/InputWeave.GameInput.0.0.1.nupkg.sha256 +++ b/eng/nuget/InputWeave.GameInput.0.0.1.nupkg.sha256 @@ -1 +1 @@ -3e9a3d65861a9211380aec6211ccf9f6b9151eae20df6d8917d3e75a98b9b589 InputWeave.GameInput.0.0.1.nupkg +3342051977f6f1b91f2a18acfc224456320264dd67633ed4bc41c8188c9fcdb7 InputWeave.GameInput.0.0.1.nupkg diff --git a/eng/nuget/InputWeave.GameInput_LICENSE.txt b/eng/nuget/InputWeave.GameInput_LICENSE.txt index e5efddc..b40dfcf 100644 --- a/eng/nuget/InputWeave.GameInput_LICENSE.txt +++ b/eng/nuget/InputWeave.GameInput_LICENSE.txt @@ -1,15 +1,15 @@ InputWeave.GameInput 0.0.1 =========================== -來源: https://github.com/rubujo/InputWeave.GameInput/tree/a3985f29c19b35365124d70cfbf0c21d1596ad3e +來源: https://github.com/rubujo/InputWeave.GameInput/tree/89b148669ffe58d9927495ed0d89f9997bf4962a 作者: https://github.com/rubujo 貢獻者: https://github.com/rubujo/InputWeave.GameInput/graphs/contributors -封裝來源: https://github.com/rubujo/InputWeave.GameInput/commit/a3985f29c19b35365124d70cfbf0c21d1596ad3e +封裝來源: https://github.com/rubujo/InputWeave.GameInput/commit/89b148669ffe58d9927495ed0d89f9997bf4962a 套件檔案: eng/nuget/InputWeave.GameInput.0.0.1.nupkg -套件 SHA256: 3e9a3d65861a9211380aec6211ccf9f6b9151eae20df6d8917d3e75a98b9b589 +套件 SHA256: 3342051977f6f1b91f2a18acfc224456320264dd67633ed4bc41c8188c9fcdb7 授權名稱: Creative Commons CC0 1.0 Universal 授權識別碼 (SPDX): CC0-1.0 -授權檔案: https://github.com/rubujo/InputWeave.GameInput/blob/a3985f29c19b35365124d70cfbf0c21d1596ad3e/LICENSE +授權檔案: https://github.com/rubujo/InputWeave.GameInput/blob/89b148669ffe58d9927495ed0d89f9997bf4962a/LICENSE InputWeave.GameInput 採用 Creative Commons CC0 1.0 Universal 公眾領域貢獻宣告。 在法律允許的最大範圍內,作者已將此套件的著作權與相關權利貢獻至全球公眾領域。 diff --git a/src/InputBox/Core/Controls/GamepadCalibrationDialog.Gamepad.cs b/src/InputBox/Core/Controls/GamepadCalibrationDialog.Gamepad.cs new file mode 100644 index 0000000..1993ed8 --- /dev/null +++ b/src/InputBox/Core/Controls/GamepadCalibrationDialog.Gamepad.cs @@ -0,0 +1,375 @@ +using InputBox.Core.Configuration; +using InputBox.Core.Extensions; +using InputBox.Core.Feedback; +using InputBox.Core.Input; +using InputBox.Core.Services; +using InputBox.Core.Utilities; +using InputBox.Resources; +using System.ComponentModel; +using System.Diagnostics; +using System.Media; + +namespace InputBox.Core.Controls; + +// 阻擋設計工具。 +partial class DesignerBlocker { }; + +/// +/// 顯示遊戲控制器校準狀態的視覺化診斷對話框(遊戲控制器與輸入分部)。 +/// 本分部檔案包含遊戲控制器事件訂閱、確認與取消、按鈕焦點導覽,以及校正狀態重設等成員。 +/// +internal sealed partial class GamepadCalibrationDialog +{ + /// + /// 指派控制器實例,並同步診斷快照與事件訂閱。 + /// + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public IGamepadController? GamepadController + { + get => _gamepadController; + set + { + if (ReferenceEquals(_gamepadController, value)) + { + return; + } + + UnsubscribeGamepadEvents(); + _gamepadController = value; + + if (_gamepadController != null) + { + SubscribeGamepadEvents(); + } + + UpdateSnapshotFromController(); + } + } + + /// + /// 訂閱目前控制器的所有輸入事件。 + /// + private void SubscribeGamepadEvents() + { + if (_gamepadController == null) + { + return; + } + + GamepadFaceButtonProfile profile = GamepadFaceButtonProfile.GetActiveProfile(); + + _gamepadController.APressed += profile.ConfirmOnSouth ? HandleGamepadConfirm : HandleGamepadCancel; + _gamepadController.StartPressed += HandleGamepadConfirm; + _gamepadController.BPressed += profile.ConfirmOnSouth ? HandleGamepadCancel : HandleGamepadConfirm; + _gamepadController.BackPressed += HandleGamepadCancel; + _gamepadController.YPressed += HandleGamescopeSurfaceRecovery; + _gamepadController.LeftPressed += HandleDPadPrevious; + _gamepadController.LeftRepeat += HandleDPadPrevious; + _gamepadController.UpPressed += HandleDPadPrevious; + _gamepadController.UpRepeat += HandleDPadPrevious; + _gamepadController.RightPressed += HandleDPadNext; + _gamepadController.RightRepeat += HandleDPadNext; + _gamepadController.DownPressed += HandleDPadNext; + _gamepadController.DownRepeat += HandleDPadNext; + _gamepadController.ConnectionChanged += HandleGamepadConnectionChanged; + } + + /// + /// 取消訂閱目前控制器的所有輸入事件。 + /// + private void UnsubscribeGamepadEvents() + { + try + { + if (_gamepadController == null) + { + return; + } + + _gamepadController.APressed -= HandleGamepadConfirm; + _gamepadController.APressed -= HandleGamepadCancel; + _gamepadController.StartPressed -= HandleGamepadConfirm; + _gamepadController.BPressed -= HandleGamepadConfirm; + _gamepadController.BPressed -= HandleGamepadCancel; + _gamepadController.BackPressed -= HandleGamepadCancel; + _gamepadController.YPressed -= HandleGamescopeSurfaceRecovery; + _gamepadController.LeftPressed -= HandleDPadPrevious; + _gamepadController.LeftRepeat -= HandleDPadPrevious; + _gamepadController.UpPressed -= HandleDPadPrevious; + _gamepadController.UpRepeat -= HandleDPadPrevious; + _gamepadController.RightPressed -= HandleDPadNext; + _gamepadController.RightRepeat -= HandleDPadNext; + _gamepadController.DownPressed -= HandleDPadNext; + _gamepadController.DownRepeat -= HandleDPadNext; + _gamepadController.ConnectionChanged -= HandleGamepadConnectionChanged; + } + catch (Exception ex) + { + Debug.WriteLine($"[GamepadCalibrationDialog] 取消訂閱控制器事件失敗:{ex.Message}"); + } + } + + /// + /// 控制器連線狀態變更時更新快照並播報連線訊息。 + /// + /// 控制器是否已連線。 + private void HandleGamepadConnectionChanged(bool isConnected) + { + try + { + this.SafeBeginInvoke(() => + { + UpdateSnapshotFromController(); + _announcer?.Announce(FormatConnectionAnnouncement(isConnected, _gamepadController?.DeviceName), true); + }); + } + catch (Exception ex) + { + LoggerService.LogException(ex, "GamepadCalibrationDialog.HandleGamepadConnectionChanged 失敗"); + Debug.WriteLine($"[GamepadCalibrationDialog] 控制器連線變更處理失敗:{ex.Message}"); + } + } + + /// + /// 處理控制器確認按鍵,觸發目前焦點按鈕或預設按鈕的點擊。 + /// + private void HandleGamepadConfirm() + { + try + { + this.SafeBeginInvoke(() => + { + try + { + if (IsDisposed) + { + return; + } + + if (ActiveControl is Button activeButton && + activeButton.Enabled) + { + activeButton.PerformClick(); + } + else + { + (_btnReset ?? AcceptButton as Button)?.PerformClick(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[GamepadCalibrationDialog] HandleGamepadConfirm UI 失敗:{ex.Message}"); + } + }); + } + catch (Exception ex) + { + Debug.WriteLine($"[GamepadCalibrationDialog] HandleGamepadConfirm 失敗:{ex.Message}"); + } + } + + /// + /// 處理控制器取消按鍵,觸發關閉按鈕或直接關閉對話框。 + /// + private void HandleGamepadCancel() + { + try + { + this.SafeBeginInvoke(() => + { + try + { + if (IsDisposed) + { + return; + } + + if (_btnClose != null && + !_btnClose.IsDisposed) + { + _btnClose.PerformClick(); + } + else + { + DialogResult = DialogResult.Cancel; + Close(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[GamepadCalibrationDialog] HandleGamepadCancel UI 失敗:{ex.Message}"); + } + }); + } + catch (Exception ex) + { + Debug.WriteLine($"[GamepadCalibrationDialog] HandleGamepadCancel 失敗:{ex.Message}"); + } + } + + /// + /// 處理 Gamescope 專用 surface recovery 組合鍵。 + /// + private void HandleGamescopeSurfaceRecovery() + { + GamescopeSurfaceRecovery.TryRecoverFromGamepadChord( + this, + RecreateHandle, + _gamepadController, + context: "GamepadCalibrationDialog Gamescope surface recovery 失敗"); + } + + /// + /// 處理 D-Pad 向前(左/上)輸入,在搖桿不干擾時移動焦點至前一個按鈕。 + /// + private void HandleDPadPrevious() + { + if (ShouldHandleDirectionalFocusNavigation(_gamepadController?.CurrentCalibrationSnapshot ?? _snapshot)) + { + FocusPreviousButton(); + } + } + + /// + /// 處理 D-Pad 向後(右/下)輸入,在搖桿不干擾時移動焦點至下一個按鈕。 + /// + private void HandleDPadNext() + { + if (ShouldHandleDirectionalFocusNavigation(_gamepadController?.CurrentCalibrationSnapshot ?? _snapshot)) + { + FocusNextButton(); + } + } + + /// + /// 將焦點移至前一個按鈕。 + /// + private void FocusPreviousButton() => MoveButtonFocus(-1); + + /// + /// 將焦點移至下一個按鈕。 + /// + private void FocusNextButton() => MoveButtonFocus(+1); + + /// + /// 依方向在重設與關閉按鈕之間移動焦點。 + /// + /// 方向值;負值移往重設按鈕,正值移往關閉按鈕。 + private void MoveButtonFocus(int direction) + { + try + { + this.SafeBeginInvoke(() => + { + if (IsDisposed || _btnReset == null || _btnClose == null) + { + return; + } + + Button nextButton = direction < 0 ? + ActiveControl == _btnClose ? _btnReset : _btnClose : + ActiveControl == _btnReset ? _btnClose : _btnReset; + + nextButton.Focus(); + }); + } + catch (Exception ex) + { + Debug.WriteLine($"[GamepadCalibrationDialog] MoveButtonFocus 失敗:{ex.Message}"); + } + } + + /// + /// 從控制器取得最新校準快照,更新狀態文字並觸發畫面重繪。 + /// + private void UpdateSnapshotFromController() + { + GamepadCalibrationSnapshot snapshot = _gamepadController?.CurrentCalibrationSnapshot ?? new GamepadCalibrationSnapshot + { + IsConnected = false, + ThumbDeadzoneEnter = AppSettings.Current.ThumbDeadzoneEnter, + ThumbDeadzoneExit = AppSettings.Current.ThumbDeadzoneExit, + TimestampUtc = DateTime.UtcNow + }; + + _snapshot = snapshot; + UpdateStatusText(); + _surface?.Invalidate(); + } + + /// + /// 判斷目前搖桿是否靜止到足以安全處理方向焦點移動,避免搖桿偏移誤觸焦點導覽。 + /// + /// 目前的校準狀態快照。 + /// 若搖桿偏移量低於安全閾值(或控制器未連線)則回傳 true。 + internal static bool ShouldHandleDirectionalFocusNavigation(GamepadCalibrationSnapshot snapshot) + { + if (!snapshot.IsConnected) + { + return true; + } + + float normalizedDeadzone = GamepadCalibrationVisualizerMapper.CalculateDeadzoneRadius( + Math.Max(snapshot.ThumbDeadzoneEnter, snapshot.ThumbDeadzoneExit)); + + // 門檻必須低於 normalizedDeadzone,確保 LS 剛跨越 ThumbDeadzoneEnter + // 觸發搖桿→D-Pad 映射的瞬間,保護邏輯已阻止方向焦點移動。 + float navigationThreshold = Math.Clamp(normalizedDeadzone * 0.75f, 0.06f, 0.18f); + + return MathF.Abs(snapshot.RawLeftX) <= navigationThreshold && + MathF.Abs(snapshot.RawLeftY) <= navigationThreshold && + MathF.Abs(snapshot.CorrectedLeftX) <= navigationThreshold && + MathF.Abs(snapshot.CorrectedLeftY) <= navigationThreshold; + } + + /// + /// 重設控制器校準資料,播放音效與震動回饋並更新快照。 + /// + private void ResetCalibration() + { + try + { + _gamepadController?.ResetCalibration(); + SystemSounds.Asterisk.Play(); + PlayResetCalibrationFeedbackAsync().SafeFireAndForget(); + UpdateSnapshotFromController(); + _announcer?.Announce(Strings.A11y_Gamepad_CalibrationReset, true); + } + catch (Exception ex) + { + LoggerService.LogException(ex, "重設校準視覺化狀態失敗"); + Debug.WriteLine($"[GamepadCalibrationDialog] ResetCalibration 失敗:{ex.Message}"); + } + } + + /// + /// 播放重設校準的三段式震動序列回饋。 + /// + /// 代表震動序列播放過程的非同步工作。 + private async Task PlayResetCalibrationFeedbackAsync() + { + IGamepadController? controller = _gamepadController; + + if (controller == null) + { + return; + } + + CancellationToken token = _cts?.Token ?? CancellationToken.None; + + VibrationProfile[] sequence = + [ + new(22000, 26, 1.0f, 0.18f, 0.52f, 0.04f), + new(22000, 26, 0.18f, 1.0f, 0.04f, 0.52f), + new(18000, 22, 0.62f, 0.62f, 0.18f, 0.18f) + ]; + + foreach (VibrationProfile profile in sequence) + { + token.ThrowIfCancellationRequested(); + await controller.VibrateAsync(profile, VibrationPriority.Normal, token); + await Task.Delay(20, token); + } + } +} diff --git a/src/InputBox/Core/Controls/GamepadCalibrationDialog.Layout.cs b/src/InputBox/Core/Controls/GamepadCalibrationDialog.Layout.cs new file mode 100644 index 0000000..c906899 --- /dev/null +++ b/src/InputBox/Core/Controls/GamepadCalibrationDialog.Layout.cs @@ -0,0 +1,177 @@ +using InputBox.Core.Configuration; +using InputBox.Core.Extensions; +using InputBox.Core.Utilities; +using System.Diagnostics; + +namespace InputBox.Core.Controls; + +// 阻擋設計工具。 +partial class DesignerBlocker { }; + +/// +/// 顯示遊戲控制器校準狀態的視覺化診斷對話框(版面配置分部)。 +/// 本分部檔案包含 DPI 變更處理、最小尺寸與智慧定位等成員。 +/// +internal sealed partial class GamepadCalibrationDialog +{ + /// + /// 視窗 Handle 建立後,更新最小尺寸並定位至初始位置。 + /// + /// 事件引數。 + protected override void OnHandleCreated(EventArgs e) + { + try + { + base.OnHandleCreated(e); + UpdateMinimumSize(forceRecalculate: true); + this.SafeBeginInvoke(ApplySmartPosition); + } + catch (Exception ex) + { + Debug.WriteLine($"[GamepadCalibrationDialog] OnHandleCreated 失敗:{ex.Message}"); + } + } + + /// + /// 使用者完成調整視窗大小後,重新套用智慧定位。 + /// + /// 事件引數。 + protected override void OnResizeEnd(EventArgs e) + { + try + { + base.OnResizeEnd(e); + ApplySmartPosition(); + } + catch (Exception ex) + { + Debug.WriteLine($"[GamepadCalibrationDialog] OnResizeEnd 失敗:{ex.Message}"); + } + } + + /// + /// DPI 變更時更新字型、最小尺寸,並重新套用智慧定位。 + /// + /// 包含新舊 DPI 值的事件引數。 + protected override void OnDpiChanged(DpiChangedEventArgs e) + { + try + { + base.OnDpiChanged(e); + this.SafeBeginInvoke(() => + { + try + { + _a11yFont = MainForm.GetSharedA11yFont(DeviceDpi); + + if (_a11yFont != null) + { + _lblIntro?.Font = _a11yFont; + + _lblStatus?.Font = _a11yFont; + + _btnReset?.Font = _a11yFont; + + _btnClose?.Font = _a11yFont; + } + + UpdateMinimumSize(forceRecalculate: true); + ApplySmartPosition(); + _surface?.Invalidate(); + } + catch (Exception ex) + { + Debug.WriteLine($"[GamepadCalibrationDialog] OnDpiChanged 延遲邏輯失敗:{ex.Message}"); + } + }); + } + catch (Exception ex) + { + Debug.WriteLine($"[GamepadCalibrationDialog] OnDpiChanged 失敗:{ex.Message}"); + } + } + + /// + /// 依目前 DPI 與可用工作區重新計算並套用對話框的最小尺寸。 + /// + /// 是否強制重新計算,忽略 DPI 未變更的快取防呆。 + private void UpdateMinimumSize(bool forceRecalculate = false) + { + try + { + if (_layoutHost == null || + _surface == null || + _lblIntro == null || + _lblStatus == null || + _btnReset == null || + _btnClose == null || + _buttonRow == null) + { + return; + } + + float currentDpi = DeviceDpi; + + if (!DialogLayoutHelper.TryBeginDpiLayout(currentDpi, ref _lastAppliedDpi, forceRecalculate)) + { + return; + } + + float scale = currentDpi / AppSettings.BaseDpi; + Rectangle workArea = Screen.GetWorkingArea(this); + (int maxFitWidth, int maxFitHeight) = DialogLayoutHelper.GetMaxFitSize(workArea); + + int targetWindowWidth = Math.Clamp((int)(600 * scale), Math.Min(maxFitWidth, (int)(420 * scale)), maxFitWidth); + int contentWidth = Math.Max(220, targetWindowWidth - Padding.Horizontal); + + _lblIntro.MaximumSize = new Size(contentWidth, 0); + _lblIntro.Margin = new Padding(0, 0, 0, (int)(6 * scale)); + + Font boldFont = MainForm.GetSharedA11yFont(DeviceDpi, FontStyle.Bold, (_a11yFont ?? Font).FontFamily); + DialogLayoutHelper.UpdateButtonMinimumSize(_btnReset, boldFont, scale, 120, 56, 32, 20); + DialogLayoutHelper.UpdateButtonMinimumSize(_btnClose, boldFont, scale, 120, 56, 32, 20); + + int buttonHeight = _buttonRow.GetPreferredSize(new Size(contentWidth, 0)).Height; + int introHeight = _lblIntro.GetPreferredSize(new Size(contentWidth, 0)).Height; + int statusHeight = Math.Max((int)(96 * scale), _lblStatus.GetPreferredSize(new Size(contentWidth, 0)).Height); + int availableSurfaceHeight = Math.Max((int)(160 * scale), maxFitHeight - Padding.Vertical - introHeight - statusHeight - buttonHeight - (int)(36 * scale)); + int surfaceHeight = Math.Min((int)(280 * scale), availableSurfaceHeight); + + _surface.MinimumSize = new Size(contentWidth, surfaceHeight); + _surface.Size = _surface.MinimumSize; + _lblStatus.MinimumSize = new Size(contentWidth, statusHeight); + _lblStatus.Size = _lblStatus.MinimumSize; + _buttonRow.WrapContents = _buttonRow.GetPreferredSize(Size.Empty).Width > contentWidth; + + int preferredHeight = _layoutHost.GetPreferredSize(new Size(contentWidth, 0)).Height + Padding.Vertical; + int targetWindowHeight = Math.Min(preferredHeight, maxFitHeight); + int minWindowWidth = Math.Min(targetWindowWidth, maxFitWidth); + int minWindowHeight = Math.Min(targetWindowHeight, maxFitHeight); + + ClientSize = new Size(minWindowWidth, targetWindowHeight); + DialogLayoutHelper.ClampFormSize(this, minWindowWidth, minWindowHeight, maxFitWidth, maxFitHeight, ApplySmartPosition); + } + catch (Exception ex) + { + Debug.WriteLine($"[GamepadCalibrationDialog] UpdateMinimumSize 失敗:{ex.Message}"); + } + } + + /// + /// 將對話框位置限制在螢幕可視範圍內,避免視窗超出邊界。 + /// + private void ApplySmartPosition() + { + try + { + if (InputBoxLayoutManager.TryGetClampedLocation(this, out Point clampedLocation)) + { + Location = clampedLocation; + } + } + catch (Exception ex) + { + Debug.WriteLine($"[GamepadCalibrationDialog] ApplySmartPosition 失敗:{ex.Message}"); + } + } +} diff --git a/src/InputBox/Core/Controls/GamepadCalibrationDialog.Visualizer.cs b/src/InputBox/Core/Controls/GamepadCalibrationDialog.Visualizer.cs new file mode 100644 index 0000000..d1c8090 --- /dev/null +++ b/src/InputBox/Core/Controls/GamepadCalibrationDialog.Visualizer.cs @@ -0,0 +1,258 @@ +using InputBox.Core.Configuration; +using InputBox.Core.Input; +using InputBox.Resources; +using System.Drawing.Drawing2D; + +namespace InputBox.Core.Controls; + +// 阻擋設計工具。 +partial class DesignerBlocker { }; + +/// +/// 顯示遊戲控制器校準狀態的視覺化診斷對話框(校正視覺化分部)。 +/// 本分部檔案包含搖桿校正繪製面板、狀態文字格式化與搖桿軌跡繪製等成員。 +/// +internal sealed partial class GamepadCalibrationDialog +{ + /// + /// 使用雙緩衝避免繪圖閃爍。 + /// + private sealed class BufferedPanel : Panel + { + /// + /// 初始化 BufferedPanel,啟用雙緩衝與縮放重繪。 + /// + public BufferedPanel() + { + DoubleBuffered = true; + ResizeRedraw = true; + TabStop = false; + } + } + + /// + /// 依目前快照更新狀態標籤文字,並同步重設按鈕的啟用狀態。 + /// + private void UpdateStatusText() + { + if (_lblStatus == null) + { + return; + } + + _lblStatus.Text = FormatStatusText(_snapshot); + + _btnReset?.Enabled = _snapshot.IsConnected; + } + + /// + /// 依連線狀態與裝置名稱產生無障礙廣播訊息字串。 + /// + /// 控制器是否已連線。 + /// 裝置名稱;可為 null。 + /// 格式化後的連線狀態廣播訊息。 + internal static string FormatConnectionAnnouncement(bool isConnected, string? deviceName) + { + string template = isConnected ? + Strings.A11y_Gamepad_Connected : + Strings.A11y_Gamepad_Disconnected; + + string message = string.Format(template, deviceName?.Trim() ?? string.Empty); + + while (message.Contains(" ", StringComparison.Ordinal)) + { + message = message.Replace(" ", " ", StringComparison.Ordinal); + } + + return message.Replace(" .", ".", StringComparison.Ordinal).Trim(); + } + + /// + /// 依校準快照產生狀態文字標籤內容。 + /// + /// 目前的校準狀態快照。 + /// 格式化後的狀態文字;控制器未連線時回傳中斷連線提示訊息。 + internal static string FormatStatusText(GamepadCalibrationSnapshot snapshot) + { + if (!snapshot.IsConnected) + { + return Strings.Dialog_GamepadCalibrationVisualizer_StatusDisconnected; + } + + return string.Format( + Strings.Dialog_GamepadCalibrationVisualizer_StatusConnected, + FormatAxis(snapshot.RawLeftX), + FormatAxis(snapshot.RawLeftY), + FormatAxis(snapshot.CorrectedLeftX), + FormatAxis(snapshot.CorrectedLeftY), + FormatAxis(snapshot.RawRightX), + FormatAxis(snapshot.RawRightY), + FormatAxis(snapshot.CorrectedRightX), + FormatAxis(snapshot.CorrectedRightY), + snapshot.ThumbDeadzoneEnter, + snapshot.ThumbDeadzoneExit); + } + + /// + /// 將正規化軸值格式化為帶符號的兩位小數字串。 + /// + /// 正規化軸值(-1.0 ~ 1.0)。 + /// 格式化後的字串,例如 "+0.75" 或 "-0.12"。 + private static string FormatAxis(float value) + { + return value.ToString("+0.00;-0.00;0.00"); + } + + /// + /// 繪製校準視覺化畫布,包含雙搖桿軌跡圖與死區圓圈。 + /// + /// 事件來源。 + /// 包含繪圖 Graphics 的事件引數。 + private void HandleSurfacePaint(object? sender, PaintEventArgs e) + { + if (_surface == null) + { + return; + } + + Graphics graphics = e.Graphics; + graphics.SmoothingMode = SmoothingMode.AntiAlias; + graphics.Clear(SystemColors.Window); + + Rectangle clientRect = _surface.ClientRectangle; + + if (clientRect.Width <= 20 || clientRect.Height <= 20) + { + return; + } + + float s = DeviceDpi / AppSettings.BaseDpi; + int margin = (int)(12 * s); + RectangleF contentBounds = new( + clientRect.Left + margin, + clientRect.Top + margin, + clientRect.Width - (margin * 2), + clientRect.Height - (margin * 2)); + + Color axisColor = SystemInformation.HighContrast ? SystemColors.WindowText : Color.DimGray; + Color deadzoneColor = SystemInformation.HighContrast ? SystemColors.Highlight : Color.FromArgb(72, 120, 120, 120); + Color rawColor = SystemInformation.HighContrast ? SystemColors.WindowText : Color.FromArgb(90, 90, 90); + Color correctedColor = SystemInformation.HighContrast ? SystemColors.Highlight : Color.DodgerBlue; + + using Pen outerPen = new(axisColor, 2f * s); + using Pen crossPen = new(axisColor, 1.5f * s) { DashStyle = DashStyle.Dash }; + using Pen deadzonePen = new(deadzoneColor, 2.5f * s); + using Pen deadzoneExitPen = new(deadzoneColor, 2f * s) { DashStyle = DashStyle.Dash }; + using Pen rawPen = new(rawColor, 2.5f * s); + using Pen correctedOutlinePen = new(axisColor, 2f * s); + using SolidBrush deadzoneFillBrush = new(Color.FromArgb(SystemInformation.HighContrast ? 60 : 48, deadzoneColor)); + using SolidBrush rawBrush = new(Color.FromArgb(SystemInformation.HighContrast ? 100 : 64, rawColor)); + using SolidBrush correctedBrush = new(correctedColor); + using SolidBrush centerBrush = new(axisColor); + + if (!_snapshot.IsConnected) + { + TextRenderer.DrawText( + graphics, + Strings.Dialog_GamepadCalibrationVisualizer_StatusDisconnected, + _a11yFont ?? Font, + Rectangle.Round(contentBounds), + axisColor, + TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.WordBreak); + + return; + } + + float labelHeight = 54f * s; + float plotGap = 16f * s; + float plotVerticalPadding = 6f * s; + float plotWidth = (contentBounds.Width - plotGap) / 2f; + float plotSize = Math.Min(plotWidth, contentBounds.Height - labelHeight - plotVerticalPadding); + float verticalOffset = (contentBounds.Height - labelHeight - plotSize) / 2f; + + RectangleF leftPlot = new( + contentBounds.Left + ((plotWidth - plotSize) / 2f), + contentBounds.Top + labelHeight + verticalOffset, + plotSize, + plotSize); + RectangleF rightPlot = new( + contentBounds.Left + plotWidth + plotGap + ((plotWidth - plotSize) / 2f), + contentBounds.Top + labelHeight + verticalOffset, + plotSize, + plotSize); + + DrawStickPlot(graphics, leftPlot, "LS", _snapshot.RawLeftX, _snapshot.RawLeftY, _snapshot.CorrectedLeftX, _snapshot.CorrectedLeftY, axisColor, outerPen, crossPen, deadzonePen, deadzoneExitPen, rawPen, correctedOutlinePen, deadzoneFillBrush, rawBrush, correctedBrush, centerBrush); + DrawStickPlot(graphics, rightPlot, "RS", _snapshot.RawRightX, _snapshot.RawRightY, _snapshot.CorrectedRightX, _snapshot.CorrectedRightY, axisColor, outerPen, crossPen, deadzonePen, deadzoneExitPen, rawPen, correctedOutlinePen, deadzoneFillBrush, rawBrush, correctedBrush, centerBrush); + } + + /// + /// 在指定範圍內繪製單一搖桿的校準圖,包含死區、原始與修正後軌跡。 + /// + /// 目標 GDI+ 繪圖物件。 + /// 搖桿圖的像素邊界矩形。 + /// 搖桿標籤(如 "LS" 或 "RS")。 + /// 原始 X 軸正規化值(-1.0 ~ 1.0)。 + /// 原始 Y 軸正規化值(-1.0 ~ 1.0)。 + /// 死區修正後 X 軸正規化值。 + /// 死區修正後 Y 軸正規化值。 + /// 座標軸與外框顏色。 + /// 外框圓圈畫筆。 + /// 十字準線畫筆。 + /// 進入死區圓圈畫筆。 + /// 退出死區虛線圓圈畫筆。 + /// 原始軌跡線畫筆。 + /// 修正點外框畫筆。 + /// 死區填滿筆刷。 + /// 原始位置填滿筆刷。 + /// 修正後位置填滿筆刷。 + /// 中心點填滿筆刷。 + private void DrawStickPlot(Graphics graphics, RectangleF plotBounds, string label, float rawX, float rawY, float correctedX, float correctedY, Color axisColor, Pen outerPen, Pen crossPen, Pen deadzonePen, Pen deadzoneExitPen, Pen rawPen, Pen correctedOutlinePen, Brush deadzoneFillBrush, Brush rawBrush, Brush correctedBrush, Brush centerBrush) + { + graphics.DrawEllipse(outerPen, plotBounds); + + float centerX = plotBounds.Left + (plotBounds.Width / 2f), + centerY = plotBounds.Top + (plotBounds.Height / 2f); + + graphics.DrawLine(crossPen, plotBounds.Left, centerY, plotBounds.Right, centerY); + graphics.DrawLine(crossPen, centerX, plotBounds.Top, centerX, plotBounds.Bottom); + + float deadzoneRadius = GamepadCalibrationVisualizerMapper.CalculateDeadzoneRadius(_snapshot.ThumbDeadzoneEnter) * (plotBounds.Width / 2f); + graphics.FillEllipse(deadzoneFillBrush, centerX - deadzoneRadius, centerY - deadzoneRadius, deadzoneRadius * 2f, deadzoneRadius * 2f); + graphics.DrawEllipse(deadzonePen, centerX - deadzoneRadius, centerY - deadzoneRadius, deadzoneRadius * 2f, deadzoneRadius * 2f); + + float exitDeadzoneRadius = GamepadCalibrationVisualizerMapper.CalculateDeadzoneRadius(_snapshot.ThumbDeadzoneExit) * (plotBounds.Width / 2f); + graphics.DrawEllipse(deadzoneExitPen, centerX - exitDeadzoneRadius, centerY - exitDeadzoneRadius, exitDeadzoneRadius * 2f, exitDeadzoneRadius * 2f); + + float dpiScale = DeviceDpi / AppSettings.BaseDpi; + graphics.FillEllipse(centerBrush, centerX - 3f * dpiScale, centerY - 3f * dpiScale, 6f * dpiScale, 6f * dpiScale); + + PointF rawPoint = GamepadCalibrationVisualizerMapper.MapToCanvas(plotBounds, rawX, rawY), + correctedPoint = GamepadCalibrationVisualizerMapper.MapToCanvas(plotBounds, correctedX, correctedY); + + graphics.DrawLine(rawPen, centerX, centerY, rawPoint.X, rawPoint.Y); + + float dm = 8f * dpiScale; + PointF[] diamond = + [ + new PointF(rawPoint.X, rawPoint.Y - dm), + new PointF(rawPoint.X + dm, rawPoint.Y), + new PointF(rawPoint.X, rawPoint.Y + dm), + new PointF(rawPoint.X - dm, rawPoint.Y) + ]; + graphics.FillPolygon(rawBrush, diamond); + graphics.DrawPolygon(rawPen, diamond); + float cr = 6f * dpiScale; + graphics.FillEllipse(correctedBrush, correctedPoint.X - cr, correctedPoint.Y - cr, cr * 2f, cr * 2f); + graphics.DrawEllipse(correctedOutlinePen, correctedPoint.X - cr, correctedPoint.Y - cr, cr * 2f, cr * 2f); + + Rectangle labelBounds = Rectangle.Round(new RectangleF(plotBounds.Left + 12f * dpiScale, plotBounds.Top - 44f * dpiScale, plotBounds.Width - 24f * dpiScale, 30f * dpiScale)); + graphics.FillRectangle(SystemBrushes.Window, labelBounds); + TextRenderer.DrawText( + graphics, + label, + _a11yFont ?? Font, + labelBounds, + axisColor, + TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); + } +} diff --git a/src/InputBox/Core/Controls/GamepadCalibrationDialog.cs b/src/InputBox/Core/Controls/GamepadCalibrationDialog.cs index cb9e69e..6efd013 100644 --- a/src/InputBox/Core/Controls/GamepadCalibrationDialog.cs +++ b/src/InputBox/Core/Controls/GamepadCalibrationDialog.cs @@ -1,14 +1,8 @@ using InputBox.Core.Configuration; using InputBox.Core.Extensions; -using InputBox.Core.Feedback; using InputBox.Core.Input; -using InputBox.Core.Services; -using InputBox.Core.Utilities; using InputBox.Resources; -using System.ComponentModel; using System.Diagnostics; -using System.Drawing.Drawing2D; -using System.Media; using System.Windows.Forms.Automation; namespace InputBox.Core.Controls; @@ -19,24 +13,8 @@ partial class DesignerBlocker { }; /// /// 顯示遊戲控制器校準狀態的視覺化診斷對話框。 /// -internal sealed class GamepadCalibrationDialog : Form +internal sealed partial class GamepadCalibrationDialog : Form { - /// - /// 使用雙緩衝避免繪圖閃爍。 - /// - private sealed class BufferedPanel : Panel - { - /// - /// 初始化 BufferedPanel,啟用雙緩衝與縮放重繪。 - /// - public BufferedPanel() - { - DoubleBuffered = true; - ResizeRedraw = true; - TabStop = false; - } - } - /// /// 目前指派的遊戲控制器實例。 /// @@ -107,33 +85,6 @@ public BufferedPanel() /// private System.Windows.Forms.Timer? _refreshTimer; - /// - /// 指派控制器實例,並同步診斷快照與事件訂閱。 - /// - [Browsable(false)] - [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] - public IGamepadController? GamepadController - { - get => _gamepadController; - set - { - if (ReferenceEquals(_gamepadController, value)) - { - return; - } - - UnsubscribeGamepadEvents(); - _gamepadController = value; - - if (_gamepadController != null) - { - SubscribeGamepadEvents(); - } - - UpdateSnapshotFromController(); - } - } - /// /// 初始化遊戲控制器校準診斷對話框,建立版面與控制項。 /// @@ -295,83 +246,6 @@ public GamepadCalibrationDialog() PerformLayout(); } - /// - /// 視窗 Handle 建立後,更新最小尺寸並定位至初始位置。 - /// - /// 事件引數。 - protected override void OnHandleCreated(EventArgs e) - { - try - { - base.OnHandleCreated(e); - UpdateMinimumSize(forceRecalculate: true); - this.SafeBeginInvoke(ApplySmartPosition); - } - catch (Exception ex) - { - Debug.WriteLine($"[GamepadCalibrationDialog] OnHandleCreated 失敗:{ex.Message}"); - } - } - - /// - /// 使用者完成調整視窗大小後,重新套用智慧定位。 - /// - /// 事件引數。 - protected override void OnResizeEnd(EventArgs e) - { - try - { - base.OnResizeEnd(e); - ApplySmartPosition(); - } - catch (Exception ex) - { - Debug.WriteLine($"[GamepadCalibrationDialog] OnResizeEnd 失敗:{ex.Message}"); - } - } - - /// - /// DPI 變更時更新字型、最小尺寸,並重新套用智慧定位。 - /// - /// 包含新舊 DPI 值的事件引數。 - protected override void OnDpiChanged(DpiChangedEventArgs e) - { - try - { - base.OnDpiChanged(e); - this.SafeBeginInvoke(() => - { - try - { - _a11yFont = MainForm.GetSharedA11yFont(DeviceDpi); - - if (_a11yFont != null) - { - _lblIntro?.Font = _a11yFont; - - _lblStatus?.Font = _a11yFont; - - _btnReset?.Font = _a11yFont; - - _btnClose?.Font = _a11yFont; - } - - UpdateMinimumSize(forceRecalculate: true); - ApplySmartPosition(); - _surface?.Invalidate(); - } - catch (Exception ex) - { - Debug.WriteLine($"[GamepadCalibrationDialog] OnDpiChanged 延遲邏輯失敗:{ex.Message}"); - } - }); - } - catch (Exception ex) - { - Debug.WriteLine($"[GamepadCalibrationDialog] OnDpiChanged 失敗:{ex.Message}"); - } - } - /// /// 對話框顯示後啟動重新整理計時器並播報開啟訊息。 /// @@ -495,642 +369,6 @@ private void HandleDialogKeyDown(object? sender, KeyEventArgs e) } } - /// - /// 訂閱目前控制器的所有輸入事件。 - /// - private void SubscribeGamepadEvents() - { - if (_gamepadController == null) - { - return; - } - - GamepadFaceButtonProfile profile = GamepadFaceButtonProfile.GetActiveProfile(); - - _gamepadController.APressed += profile.ConfirmOnSouth ? HandleGamepadConfirm : HandleGamepadCancel; - _gamepadController.StartPressed += HandleGamepadConfirm; - _gamepadController.BPressed += profile.ConfirmOnSouth ? HandleGamepadCancel : HandleGamepadConfirm; - _gamepadController.BackPressed += HandleGamepadCancel; - _gamepadController.YPressed += HandleGamescopeSurfaceRecovery; - _gamepadController.LeftPressed += HandleDPadPrevious; - _gamepadController.LeftRepeat += HandleDPadPrevious; - _gamepadController.UpPressed += HandleDPadPrevious; - _gamepadController.UpRepeat += HandleDPadPrevious; - _gamepadController.RightPressed += HandleDPadNext; - _gamepadController.RightRepeat += HandleDPadNext; - _gamepadController.DownPressed += HandleDPadNext; - _gamepadController.DownRepeat += HandleDPadNext; - _gamepadController.ConnectionChanged += HandleGamepadConnectionChanged; - } - - /// - /// 取消訂閱目前控制器的所有輸入事件。 - /// - private void UnsubscribeGamepadEvents() - { - try - { - if (_gamepadController == null) - { - return; - } - - _gamepadController.APressed -= HandleGamepadConfirm; - _gamepadController.APressed -= HandleGamepadCancel; - _gamepadController.StartPressed -= HandleGamepadConfirm; - _gamepadController.BPressed -= HandleGamepadConfirm; - _gamepadController.BPressed -= HandleGamepadCancel; - _gamepadController.BackPressed -= HandleGamepadCancel; - _gamepadController.YPressed -= HandleGamescopeSurfaceRecovery; - _gamepadController.LeftPressed -= HandleDPadPrevious; - _gamepadController.LeftRepeat -= HandleDPadPrevious; - _gamepadController.UpPressed -= HandleDPadPrevious; - _gamepadController.UpRepeat -= HandleDPadPrevious; - _gamepadController.RightPressed -= HandleDPadNext; - _gamepadController.RightRepeat -= HandleDPadNext; - _gamepadController.DownPressed -= HandleDPadNext; - _gamepadController.DownRepeat -= HandleDPadNext; - _gamepadController.ConnectionChanged -= HandleGamepadConnectionChanged; - } - catch (Exception ex) - { - Debug.WriteLine($"[GamepadCalibrationDialog] 取消訂閱控制器事件失敗:{ex.Message}"); - } - } - - /// - /// 控制器連線狀態變更時更新快照並播報連線訊息。 - /// - /// 控制器是否已連線。 - private void HandleGamepadConnectionChanged(bool isConnected) - { - try - { - this.SafeBeginInvoke(() => - { - UpdateSnapshotFromController(); - _announcer?.Announce(FormatConnectionAnnouncement(isConnected, _gamepadController?.DeviceName), true); - }); - } - catch (Exception ex) - { - LoggerService.LogException(ex, "GamepadCalibrationDialog.HandleGamepadConnectionChanged 失敗"); - Debug.WriteLine($"[GamepadCalibrationDialog] 控制器連線變更處理失敗:{ex.Message}"); - } - } - - /// - /// 處理控制器確認按鍵,觸發目前焦點按鈕或預設按鈕的點擊。 - /// - private void HandleGamepadConfirm() - { - try - { - this.SafeBeginInvoke(() => - { - try - { - if (IsDisposed) - { - return; - } - - if (ActiveControl is Button activeButton && - activeButton.Enabled) - { - activeButton.PerformClick(); - } - else - { - (_btnReset ?? AcceptButton as Button)?.PerformClick(); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[GamepadCalibrationDialog] HandleGamepadConfirm UI 失敗:{ex.Message}"); - } - }); - } - catch (Exception ex) - { - Debug.WriteLine($"[GamepadCalibrationDialog] HandleGamepadConfirm 失敗:{ex.Message}"); - } - } - - /// - /// 處理控制器取消按鍵,觸發關閉按鈕或直接關閉對話框。 - /// - private void HandleGamepadCancel() - { - try - { - this.SafeBeginInvoke(() => - { - try - { - if (IsDisposed) - { - return; - } - - if (_btnClose != null && - !_btnClose.IsDisposed) - { - _btnClose.PerformClick(); - } - else - { - DialogResult = DialogResult.Cancel; - Close(); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[GamepadCalibrationDialog] HandleGamepadCancel UI 失敗:{ex.Message}"); - } - }); - } - catch (Exception ex) - { - Debug.WriteLine($"[GamepadCalibrationDialog] HandleGamepadCancel 失敗:{ex.Message}"); - } - } - - /// - /// 處理 Gamescope 專用 surface recovery 組合鍵。 - /// - private void HandleGamescopeSurfaceRecovery() - { - GamescopeSurfaceRecovery.TryRecoverFromGamepadChord( - this, - RecreateHandle, - _gamepadController, - context: "GamepadCalibrationDialog Gamescope surface recovery 失敗"); - } - - /// - /// 處理 D-Pad 向前(左/上)輸入,在搖桿不干擾時移動焦點至前一個按鈕。 - /// - private void HandleDPadPrevious() - { - if (ShouldHandleDirectionalFocusNavigation(_gamepadController?.CurrentCalibrationSnapshot ?? _snapshot)) - { - FocusPreviousButton(); - } - } - - /// - /// 處理 D-Pad 向後(右/下)輸入,在搖桿不干擾時移動焦點至下一個按鈕。 - /// - private void HandleDPadNext() - { - if (ShouldHandleDirectionalFocusNavigation(_gamepadController?.CurrentCalibrationSnapshot ?? _snapshot)) - { - FocusNextButton(); - } - } - - /// - /// 將焦點移至前一個按鈕。 - /// - private void FocusPreviousButton() => MoveButtonFocus(-1); - - /// - /// 將焦點移至下一個按鈕。 - /// - private void FocusNextButton() => MoveButtonFocus(+1); - - /// - /// 依方向在重設與關閉按鈕之間移動焦點。 - /// - /// 方向值;負值移往重設按鈕,正值移往關閉按鈕。 - private void MoveButtonFocus(int direction) - { - try - { - this.SafeBeginInvoke(() => - { - if (IsDisposed || _btnReset == null || _btnClose == null) - { - return; - } - - Button nextButton = direction < 0 ? - ActiveControl == _btnClose ? _btnReset : _btnClose : - ActiveControl == _btnReset ? _btnClose : _btnReset; - - nextButton.Focus(); - }); - } - catch (Exception ex) - { - Debug.WriteLine($"[GamepadCalibrationDialog] MoveButtonFocus 失敗:{ex.Message}"); - } - } - - /// - /// 從控制器取得最新校準快照,更新狀態文字並觸發畫面重繪。 - /// - private void UpdateSnapshotFromController() - { - GamepadCalibrationSnapshot snapshot = _gamepadController?.CurrentCalibrationSnapshot ?? new GamepadCalibrationSnapshot - { - IsConnected = false, - ThumbDeadzoneEnter = AppSettings.Current.ThumbDeadzoneEnter, - ThumbDeadzoneExit = AppSettings.Current.ThumbDeadzoneExit, - TimestampUtc = DateTime.UtcNow - }; - - _snapshot = snapshot; - UpdateStatusText(); - _surface?.Invalidate(); - } - - /// - /// 依目前快照更新狀態標籤文字,並同步重設按鈕的啟用狀態。 - /// - private void UpdateStatusText() - { - if (_lblStatus == null) - { - return; - } - - _lblStatus.Text = FormatStatusText(_snapshot); - - _btnReset?.Enabled = _snapshot.IsConnected; - } - - /// - /// 依連線狀態與裝置名稱產生無障礙廣播訊息字串。 - /// - /// 控制器是否已連線。 - /// 裝置名稱;可為 null。 - /// 格式化後的連線狀態廣播訊息。 - internal static string FormatConnectionAnnouncement(bool isConnected, string? deviceName) - { - string template = isConnected ? - Strings.A11y_Gamepad_Connected : - Strings.A11y_Gamepad_Disconnected; - - string message = string.Format(template, deviceName?.Trim() ?? string.Empty); - - while (message.Contains(" ", StringComparison.Ordinal)) - { - message = message.Replace(" ", " ", StringComparison.Ordinal); - } - - return message.Replace(" .", ".", StringComparison.Ordinal).Trim(); - } - - /// - /// 判斷目前搖桿是否靜止到足以安全處理方向焦點移動,避免搖桿偏移誤觸焦點導覽。 - /// - /// 目前的校準狀態快照。 - /// 若搖桿偏移量低於安全閾值(或控制器未連線)則回傳 true。 - internal static bool ShouldHandleDirectionalFocusNavigation(GamepadCalibrationSnapshot snapshot) - { - if (!snapshot.IsConnected) - { - return true; - } - - float normalizedDeadzone = GamepadCalibrationVisualizerMapper.CalculateDeadzoneRadius( - Math.Max(snapshot.ThumbDeadzoneEnter, snapshot.ThumbDeadzoneExit)); - - // 門檻必須低於 normalizedDeadzone,確保 LS 剛跨越 ThumbDeadzoneEnter - // 觸發搖桿→D-Pad 映射的瞬間,保護邏輯已阻止方向焦點移動。 - float navigationThreshold = Math.Clamp(normalizedDeadzone * 0.75f, 0.06f, 0.18f); - - return MathF.Abs(snapshot.RawLeftX) <= navigationThreshold && - MathF.Abs(snapshot.RawLeftY) <= navigationThreshold && - MathF.Abs(snapshot.CorrectedLeftX) <= navigationThreshold && - MathF.Abs(snapshot.CorrectedLeftY) <= navigationThreshold; - } - - /// - /// 依校準快照產生狀態文字標籤內容。 - /// - /// 目前的校準狀態快照。 - /// 格式化後的狀態文字;控制器未連線時回傳中斷連線提示訊息。 - internal static string FormatStatusText(GamepadCalibrationSnapshot snapshot) - { - if (!snapshot.IsConnected) - { - return Strings.Dialog_GamepadCalibrationVisualizer_StatusDisconnected; - } - - return string.Format( - Strings.Dialog_GamepadCalibrationVisualizer_StatusConnected, - FormatAxis(snapshot.RawLeftX), - FormatAxis(snapshot.RawLeftY), - FormatAxis(snapshot.CorrectedLeftX), - FormatAxis(snapshot.CorrectedLeftY), - FormatAxis(snapshot.RawRightX), - FormatAxis(snapshot.RawRightY), - FormatAxis(snapshot.CorrectedRightX), - FormatAxis(snapshot.CorrectedRightY), - snapshot.ThumbDeadzoneEnter, - snapshot.ThumbDeadzoneExit); - } - - /// - /// 將正規化軸值格式化為帶符號的兩位小數字串。 - /// - /// 正規化軸值(-1.0 ~ 1.0)。 - /// 格式化後的字串,例如 "+0.75" 或 "-0.12"。 - private static string FormatAxis(float value) - { - return value.ToString("+0.00;-0.00;0.00"); - } - - /// - /// 重設控制器校準資料,播放音效與震動回饋並更新快照。 - /// - private void ResetCalibration() - { - try - { - _gamepadController?.ResetCalibration(); - SystemSounds.Asterisk.Play(); - PlayResetCalibrationFeedbackAsync().SafeFireAndForget(); - UpdateSnapshotFromController(); - _announcer?.Announce(Strings.A11y_Gamepad_CalibrationReset, true); - } - catch (Exception ex) - { - LoggerService.LogException(ex, "重設校準視覺化狀態失敗"); - Debug.WriteLine($"[GamepadCalibrationDialog] ResetCalibration 失敗:{ex.Message}"); - } - } - - /// - /// 播放重設校準的三段式震動序列回饋。 - /// - /// 代表震動序列播放過程的非同步工作。 - private async Task PlayResetCalibrationFeedbackAsync() - { - IGamepadController? controller = _gamepadController; - - if (controller == null) - { - return; - } - - CancellationToken token = _cts?.Token ?? CancellationToken.None; - - VibrationProfile[] sequence = - [ - new(22000, 26, 1.0f, 0.18f, 0.52f, 0.04f), - new(22000, 26, 0.18f, 1.0f, 0.04f, 0.52f), - new(18000, 22, 0.62f, 0.62f, 0.18f, 0.18f) - ]; - - foreach (VibrationProfile profile in sequence) - { - token.ThrowIfCancellationRequested(); - await controller.VibrateAsync(profile, VibrationPriority.Normal, token); - await Task.Delay(20, token); - } - } - - /// - /// 繪製校準視覺化畫布,包含雙搖桿軌跡圖與死區圓圈。 - /// - /// 事件來源。 - /// 包含繪圖 Graphics 的事件引數。 - private void HandleSurfacePaint(object? sender, PaintEventArgs e) - { - if (_surface == null) - { - return; - } - - Graphics graphics = e.Graphics; - graphics.SmoothingMode = SmoothingMode.AntiAlias; - graphics.Clear(SystemColors.Window); - - Rectangle clientRect = _surface.ClientRectangle; - - if (clientRect.Width <= 20 || clientRect.Height <= 20) - { - return; - } - - float s = DeviceDpi / AppSettings.BaseDpi; - int margin = (int)(12 * s); - RectangleF contentBounds = new( - clientRect.Left + margin, - clientRect.Top + margin, - clientRect.Width - (margin * 2), - clientRect.Height - (margin * 2)); - - Color axisColor = SystemInformation.HighContrast ? SystemColors.WindowText : Color.DimGray; - Color deadzoneColor = SystemInformation.HighContrast ? SystemColors.Highlight : Color.FromArgb(72, 120, 120, 120); - Color rawColor = SystemInformation.HighContrast ? SystemColors.WindowText : Color.FromArgb(90, 90, 90); - Color correctedColor = SystemInformation.HighContrast ? SystemColors.Highlight : Color.DodgerBlue; - - using Pen outerPen = new(axisColor, 2f * s); - using Pen crossPen = new(axisColor, 1.5f * s) { DashStyle = DashStyle.Dash }; - using Pen deadzonePen = new(deadzoneColor, 2.5f * s); - using Pen deadzoneExitPen = new(deadzoneColor, 2f * s) { DashStyle = DashStyle.Dash }; - using Pen rawPen = new(rawColor, 2.5f * s); - using Pen correctedOutlinePen = new(axisColor, 2f * s); - using SolidBrush deadzoneFillBrush = new(Color.FromArgb(SystemInformation.HighContrast ? 60 : 48, deadzoneColor)); - using SolidBrush rawBrush = new(Color.FromArgb(SystemInformation.HighContrast ? 100 : 64, rawColor)); - using SolidBrush correctedBrush = new(correctedColor); - using SolidBrush centerBrush = new(axisColor); - - if (!_snapshot.IsConnected) - { - TextRenderer.DrawText( - graphics, - Strings.Dialog_GamepadCalibrationVisualizer_StatusDisconnected, - _a11yFont ?? Font, - Rectangle.Round(contentBounds), - axisColor, - TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.WordBreak); - - return; - } - - float labelHeight = 54f * s; - float plotGap = 16f * s; - float plotVerticalPadding = 6f * s; - float plotWidth = (contentBounds.Width - plotGap) / 2f; - float plotSize = Math.Min(plotWidth, contentBounds.Height - labelHeight - plotVerticalPadding); - float verticalOffset = (contentBounds.Height - labelHeight - plotSize) / 2f; - - RectangleF leftPlot = new( - contentBounds.Left + ((plotWidth - plotSize) / 2f), - contentBounds.Top + labelHeight + verticalOffset, - plotSize, - plotSize); - RectangleF rightPlot = new( - contentBounds.Left + plotWidth + plotGap + ((plotWidth - plotSize) / 2f), - contentBounds.Top + labelHeight + verticalOffset, - plotSize, - plotSize); - - DrawStickPlot(graphics, leftPlot, "LS", _snapshot.RawLeftX, _snapshot.RawLeftY, _snapshot.CorrectedLeftX, _snapshot.CorrectedLeftY, axisColor, outerPen, crossPen, deadzonePen, deadzoneExitPen, rawPen, correctedOutlinePen, deadzoneFillBrush, rawBrush, correctedBrush, centerBrush); - DrawStickPlot(graphics, rightPlot, "RS", _snapshot.RawRightX, _snapshot.RawRightY, _snapshot.CorrectedRightX, _snapshot.CorrectedRightY, axisColor, outerPen, crossPen, deadzonePen, deadzoneExitPen, rawPen, correctedOutlinePen, deadzoneFillBrush, rawBrush, correctedBrush, centerBrush); - } - - /// - /// 在指定範圍內繪製單一搖桿的校準圖,包含死區、原始與修正後軌跡。 - /// - /// 目標 GDI+ 繪圖物件。 - /// 搖桿圖的像素邊界矩形。 - /// 搖桿標籤(如 "LS" 或 "RS")。 - /// 原始 X 軸正規化值(-1.0 ~ 1.0)。 - /// 原始 Y 軸正規化值(-1.0 ~ 1.0)。 - /// 死區修正後 X 軸正規化值。 - /// 死區修正後 Y 軸正規化值。 - /// 座標軸與外框顏色。 - /// 外框圓圈畫筆。 - /// 十字準線畫筆。 - /// 進入死區圓圈畫筆。 - /// 退出死區虛線圓圈畫筆。 - /// 原始軌跡線畫筆。 - /// 修正點外框畫筆。 - /// 死區填滿筆刷。 - /// 原始位置填滿筆刷。 - /// 修正後位置填滿筆刷。 - /// 中心點填滿筆刷。 - private void DrawStickPlot(Graphics graphics, RectangleF plotBounds, string label, float rawX, float rawY, float correctedX, float correctedY, Color axisColor, Pen outerPen, Pen crossPen, Pen deadzonePen, Pen deadzoneExitPen, Pen rawPen, Pen correctedOutlinePen, Brush deadzoneFillBrush, Brush rawBrush, Brush correctedBrush, Brush centerBrush) - { - graphics.DrawEllipse(outerPen, plotBounds); - - float centerX = plotBounds.Left + (plotBounds.Width / 2f), - centerY = plotBounds.Top + (plotBounds.Height / 2f); - - graphics.DrawLine(crossPen, plotBounds.Left, centerY, plotBounds.Right, centerY); - graphics.DrawLine(crossPen, centerX, plotBounds.Top, centerX, plotBounds.Bottom); - - float deadzoneRadius = GamepadCalibrationVisualizerMapper.CalculateDeadzoneRadius(_snapshot.ThumbDeadzoneEnter) * (plotBounds.Width / 2f); - graphics.FillEllipse(deadzoneFillBrush, centerX - deadzoneRadius, centerY - deadzoneRadius, deadzoneRadius * 2f, deadzoneRadius * 2f); - graphics.DrawEllipse(deadzonePen, centerX - deadzoneRadius, centerY - deadzoneRadius, deadzoneRadius * 2f, deadzoneRadius * 2f); - - float exitDeadzoneRadius = GamepadCalibrationVisualizerMapper.CalculateDeadzoneRadius(_snapshot.ThumbDeadzoneExit) * (plotBounds.Width / 2f); - graphics.DrawEllipse(deadzoneExitPen, centerX - exitDeadzoneRadius, centerY - exitDeadzoneRadius, exitDeadzoneRadius * 2f, exitDeadzoneRadius * 2f); - - float dpiScale = DeviceDpi / AppSettings.BaseDpi; - graphics.FillEllipse(centerBrush, centerX - 3f * dpiScale, centerY - 3f * dpiScale, 6f * dpiScale, 6f * dpiScale); - - PointF rawPoint = GamepadCalibrationVisualizerMapper.MapToCanvas(plotBounds, rawX, rawY), - correctedPoint = GamepadCalibrationVisualizerMapper.MapToCanvas(plotBounds, correctedX, correctedY); - - graphics.DrawLine(rawPen, centerX, centerY, rawPoint.X, rawPoint.Y); - - float dm = 8f * dpiScale; - PointF[] diamond = - [ - new PointF(rawPoint.X, rawPoint.Y - dm), - new PointF(rawPoint.X + dm, rawPoint.Y), - new PointF(rawPoint.X, rawPoint.Y + dm), - new PointF(rawPoint.X - dm, rawPoint.Y) - ]; - graphics.FillPolygon(rawBrush, diamond); - graphics.DrawPolygon(rawPen, diamond); - float cr = 6f * dpiScale; - graphics.FillEllipse(correctedBrush, correctedPoint.X - cr, correctedPoint.Y - cr, cr * 2f, cr * 2f); - graphics.DrawEllipse(correctedOutlinePen, correctedPoint.X - cr, correctedPoint.Y - cr, cr * 2f, cr * 2f); - - Rectangle labelBounds = Rectangle.Round(new RectangleF(plotBounds.Left + 12f * dpiScale, plotBounds.Top - 44f * dpiScale, plotBounds.Width - 24f * dpiScale, 30f * dpiScale)); - graphics.FillRectangle(SystemBrushes.Window, labelBounds); - TextRenderer.DrawText( - graphics, - label, - _a11yFont ?? Font, - labelBounds, - axisColor, - TextFormatFlags.HorizontalCenter | TextFormatFlags.VerticalCenter | TextFormatFlags.EndEllipsis); - } - - /// - /// 依目前 DPI 與可用工作區重新計算並套用對話框的最小尺寸。 - /// - /// 是否強制重新計算,忽略 DPI 未變更的快取防呆。 - private void UpdateMinimumSize(bool forceRecalculate = false) - { - try - { - if (_layoutHost == null || - _surface == null || - _lblIntro == null || - _lblStatus == null || - _btnReset == null || - _btnClose == null || - _buttonRow == null) - { - return; - } - - float currentDpi = DeviceDpi; - - if (!DialogLayoutHelper.TryBeginDpiLayout(currentDpi, ref _lastAppliedDpi, forceRecalculate)) - { - return; - } - - float scale = currentDpi / AppSettings.BaseDpi; - Rectangle workArea = Screen.GetWorkingArea(this); - (int maxFitWidth, int maxFitHeight) = DialogLayoutHelper.GetMaxFitSize(workArea); - - int targetWindowWidth = Math.Clamp((int)(600 * scale), Math.Min(maxFitWidth, (int)(420 * scale)), maxFitWidth); - int contentWidth = Math.Max(220, targetWindowWidth - Padding.Horizontal); - - _lblIntro.MaximumSize = new Size(contentWidth, 0); - _lblIntro.Margin = new Padding(0, 0, 0, (int)(6 * scale)); - - Font boldFont = MainForm.GetSharedA11yFont(DeviceDpi, FontStyle.Bold, (_a11yFont ?? Font).FontFamily); - DialogLayoutHelper.UpdateButtonMinimumSize(_btnReset, boldFont, scale, 120, 56, 32, 20); - DialogLayoutHelper.UpdateButtonMinimumSize(_btnClose, boldFont, scale, 120, 56, 32, 20); - - int buttonHeight = _buttonRow.GetPreferredSize(new Size(contentWidth, 0)).Height; - int introHeight = _lblIntro.GetPreferredSize(new Size(contentWidth, 0)).Height; - int statusHeight = Math.Max((int)(96 * scale), _lblStatus.GetPreferredSize(new Size(contentWidth, 0)).Height); - int availableSurfaceHeight = Math.Max((int)(160 * scale), maxFitHeight - Padding.Vertical - introHeight - statusHeight - buttonHeight - (int)(36 * scale)); - int surfaceHeight = Math.Min((int)(280 * scale), availableSurfaceHeight); - - _surface.MinimumSize = new Size(contentWidth, surfaceHeight); - _surface.Size = _surface.MinimumSize; - _lblStatus.MinimumSize = new Size(contentWidth, statusHeight); - _lblStatus.Size = _lblStatus.MinimumSize; - _buttonRow.WrapContents = _buttonRow.GetPreferredSize(Size.Empty).Width > contentWidth; - - int preferredHeight = _layoutHost.GetPreferredSize(new Size(contentWidth, 0)).Height + Padding.Vertical; - int targetWindowHeight = Math.Min(preferredHeight, maxFitHeight); - int minWindowWidth = Math.Min(targetWindowWidth, maxFitWidth); - int minWindowHeight = Math.Min(targetWindowHeight, maxFitHeight); - - ClientSize = new Size(minWindowWidth, targetWindowHeight); - DialogLayoutHelper.ClampFormSize(this, minWindowWidth, minWindowHeight, maxFitWidth, maxFitHeight, ApplySmartPosition); - } - catch (Exception ex) - { - Debug.WriteLine($"[GamepadCalibrationDialog] UpdateMinimumSize 失敗:{ex.Message}"); - } - } - - /// - /// 將對話框位置限制在螢幕可視範圍內,避免視窗超出邊界。 - /// - private void ApplySmartPosition() - { - try - { - if (InputBoxLayoutManager.TryGetClampedLocation(this, out Point clampedLocation)) - { - Location = clampedLocation; - } - } - catch (Exception ex) - { - Debug.WriteLine($"[GamepadCalibrationDialog] ApplySmartPosition 失敗:{ex.Message}"); - } - } - /// /// 建立符合眼球追蹤規格的大型按鈕,並附加懸停回饋。 /// @@ -1215,4 +453,4 @@ protected override void Dispose(bool disposing) base.Dispose(disposing); } -} \ No newline at end of file +} diff --git a/src/InputBox/Core/Controls/NumericInputDialog.A11y.cs b/src/InputBox/Core/Controls/NumericInputDialog.A11y.cs new file mode 100644 index 0000000..a7cf810 --- /dev/null +++ b/src/InputBox/Core/Controls/NumericInputDialog.A11y.cs @@ -0,0 +1,158 @@ +using InputBox.Core.Configuration; +using InputBox.Core.Extensions; +using InputBox.Core.Feedback; +using System.Diagnostics; + +namespace InputBox.Core.Controls; + +// 阻擋設計工具。 +partial class DesignerBlocker { }; + +/// +/// 專門用於數值輸入的對話框(無障礙輔助功能分部)。 +/// 本分部檔案包含 A11y 廣播與視覺閃爍警示等成員。 +/// +internal sealed partial class NumericInputDialog +{ + /// + /// 執行視覺警示閃爍效果 + /// + /// Task + private async Task FlashAlertAsync() + { + if (IsDisposed || + !IsHandleCreated || + Interlocked.CompareExchange(ref _isFlashing, 1, 0) != 0) + { + return; + } + + // 僅在對話框生命週期仍有效時建立警示權杖,避免關閉途中留下失去連結的動畫。 + CancellationTokenSource? newAlertCts = _cts.TryCreateLinkedTokenSource(); + + if (newAlertCts == null) + { + Interlocked.Exchange(ref _isFlashing, 0); + + return; + } + + Interlocked.Exchange(ref _alertCts, newAlertCts)?.CancelAndDispose(); + + CancellationToken token = newAlertCts.Token; + + try + { + bool isDark = this.IsDarkModeActive(); + Color alertColor = FlashAlertAnimator.GetAlertColor(isDark, SystemInformation.HighContrast); + + void ApplyAlertVisuals(float intensity) + { + if (IsDisposed || + !IsHandleCreated || + _nud == null) + { + return; + } + + (Color back, Color fore) = FlashAlertAnimator.ComputeFrameColors( + intensity, + isDark, + alertColor, + SystemInformation.HighContrast); + + _nud.UpdateRecursive(back, fore); + } + + await FlashAlertAnimator.RunAsync(this, ApplyAlertVisuals, token); + } + catch (OperationCanceledException) + { + // 正常取消。 + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] FlashAlertAsync 失敗:{ex.Message}"); + } + finally + { + Interlocked.Exchange(ref _isFlashing, 0); + Interlocked.Exchange(ref _alertCts, null)?.CancelAndDispose(); + + // 確保 UI 狀態還原。 + this.SafeInvoke(() => + { + try + { + if (IsDisposed || + !IsHandleCreated || + _nud == null) + { + return; + } + + // 關鍵修正:遞歸重設顏色,觸發 .NET 10 原生主題引擎還原正確配色,防止閃爍殘留。 + _nud.ResetThemeRecursive(); + + // 恢復焦點視覺狀態。 + UpdateFocusVisuals(_nud.Focused || _nud.ContainsFocus); + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] FlashAlertAsync 還原失敗:{ex.Message}"); + } + }); + } + } + + /// + /// 內部 A11y 廣播方法 + /// + /// 要廣播的訊息 + /// 是否中斷目前的廣播 + private void AnnounceA11y( + string message, + bool interrupt = false) + { + if (IsDisposed || + string.IsNullOrEmpty(message)) + { + return; + } + + long currentId = Interlocked.Increment(ref _a11yDebounceId); + + Task.Run(async () => + { + try + { + // 統一 Audio Ducking 避讓延遲。 + await Task.Delay(AppSettings.AudioDuckingDelayMs, _cts?.Token ?? CancellationToken.None); + + if (Interlocked.Read(ref _a11yDebounceId) == currentId && + !IsDisposed && + IsHandleCreated) + { + await this.SafeInvokeAsync(() => + _announcer?.Announce(message, interrupt && AppSettings.Current.A11yInterruptEnabled)); + } + } + catch (OperationCanceledException) + { + // 正常取消。 + } + catch (Exception ex) + { + Debug.WriteLine($"[A11y] 對話框本地廣播失敗:{ex.Message}"); + } + }, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + + /// + /// 取得目前對話框所屬的主視窗 + /// + /// 若 Owner 為 則回傳該實例,否則為 null。 + private MainForm? GetOwnerMainForm() => Owner as MainForm; +} diff --git a/src/InputBox/Core/Controls/NumericInputDialog.AccessibleNumericUpDown.cs b/src/InputBox/Core/Controls/NumericInputDialog.AccessibleNumericUpDown.cs new file mode 100644 index 0000000..00b1de9 --- /dev/null +++ b/src/InputBox/Core/Controls/NumericInputDialog.AccessibleNumericUpDown.cs @@ -0,0 +1,130 @@ +using System.Diagnostics; + +namespace InputBox.Core.Controls; + +// 阻擋設計工具。 +partial class DesignerBlocker { }; + +/// +/// 專門用於數值輸入的對話框(無障礙數值輸入控制項分部)。 +/// 本分部檔案包含內嵌的 AccessibleNumericUpDown 控制項。 +/// +internal sealed partial class NumericInputDialog +{ + /// + /// 繼承自 NumericUpDown 以公開受保護的成員方法 + /// + /// 父對話框實例 + private sealed class AccessibleNumericUpDown(NumericInputDialog parent) : NumericUpDown + { + /// + /// 父 NumericInputDialog 實例,用於回呼邊界撞牆通知。 + /// + private readonly NumericInputDialog _parent = parent; + + /// + /// 遞增數值,若已達上限則通知父對話框播放邊界回饋。 + /// + public override void UpButton() + { + try + { + decimal oldValue = Value; + + base.UpButton(); + + if (Value == oldValue) + { + _parent.HandleBoundaryHit(true); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericUpDown] UpButton 失敗:{ex.Message}"); + } + } + + /// + /// 遞減數值,若已達下限則通知父對話框播放邊界回饋。 + /// + public override void DownButton() + { + try + { + decimal oldValue = Value; + + base.DownButton(); + + if (Value == oldValue) + { + _parent.HandleBoundaryHit(false); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericUpDown] DownButton 失敗:{ex.Message}"); + } + } + + /// + /// 主動觸發無障礙狀態變更通知 + /// + public void NotifyAccessibilityChange() + { + try + { + // 主動通知輔助科技(AT)數值已變更。 + AccessibilityNotifyClients(AccessibleEvents.ValueChange, -1); + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericUpDown] NotifyAccessibilityChange 失敗:{ex.Message}"); + } + } + + /// + /// 強制驗證編輯文字,確保 Value 屬性與目前輸入內容同步 + /// + public void ValidateValue() + { + try + { + ValidateEditText(); + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericUpDown] ValidateValue 失敗:{ex.Message}"); + } + } + + /// + /// 覆寫滑鼠滾輪行為,確保「一格跳一格」 + /// + /// MouseEventArgs + protected override void OnMouseWheel(MouseEventArgs e) + { + try + { + // 強制攔截並阻斷 Windows 系統的「一次捲動多行」設定(預設為 3)。 + if (e is HandledMouseEventArgs hme) + { + hme.Handled = true; + } + + // 手動精確執行單次增減,不調用 base.OnMouseWheel(e)。 + if (e.Delta > 0) + { + UpButton(); + } + else if (e.Delta < 0) + { + DownButton(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericUpDown] OnMouseWheel 失敗:{ex.Message}"); + } + } + } +} diff --git a/src/InputBox/Core/Controls/NumericInputDialog.Gamepad.cs b/src/InputBox/Core/Controls/NumericInputDialog.Gamepad.cs new file mode 100644 index 0000000..2692e92 --- /dev/null +++ b/src/InputBox/Core/Controls/NumericInputDialog.Gamepad.cs @@ -0,0 +1,629 @@ +using InputBox.Core.Configuration; +using InputBox.Core.Extensions; +using InputBox.Core.Feedback; +using InputBox.Core.Input; +using InputBox.Core.Services; +using InputBox.Resources; +using System.ComponentModel; +using System.Diagnostics; +using System.Media; + +namespace InputBox.Core.Controls; + +// 阻擋設計工具。 +partial class DesignerBlocker { }; + +/// +/// 專門用於數值輸入的對話框(遊戲控制器與輸入分部)。 +/// 本分部檔案包含遊戲控制器事件、數值加減、游標移動、延伸選取、刪除,以及觸控式鍵盤等輸入處理成員。 +/// +internal sealed partial class NumericInputDialog +{ + /// + /// 設定控制器實作,並訂閱事件 + /// + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public IGamepadController? GamepadController + { + get => _gamepadController; + set + { + // 如果控制器相同,不執行任何操作。 + if (ReferenceEquals(_gamepadController, value)) + { + return; + } + + // 安全清理舊控制器的訂閱。 + UnsubscribeGamepadEvents(); + + _gamepadController = value; + + if (_gamepadController != null) + { + GamepadFaceButtonProfile profile = GamepadFaceButtonProfile.GetActiveProfile(); + + // 訂閱新控制器事件。 + _gamepadController.UpPressed += HandlePlus; + _gamepadController.UpRepeat += HandlePlus; + _gamepadController.DownPressed += HandleMinus; + _gamepadController.DownRepeat += HandleMinus; + _gamepadController.LeftPressed += HandleLeft; + _gamepadController.LeftRepeat += HandleLeft; + _gamepadController.RightPressed += HandleRight; + _gamepadController.RightRepeat += HandleRight; + _gamepadController.RSLeftPressed += HandleRSLeft; + _gamepadController.RSLeftRepeat += HandleRSLeft; + _gamepadController.RSRightPressed += HandleRSRight; + _gamepadController.RSRightRepeat += HandleRSRight; + _gamepadController.APressed += profile.ConfirmOnSouth ? HandleGamepadA : HandleCancel; + _gamepadController.StartPressed += HandleOpenTouchKeyboardFromGamepad; + _gamepadController.BPressed += profile.ConfirmOnSouth ? HandleCancel : HandleGamepadA; + _gamepadController.BackPressed += HandleCancel; + _gamepadController.XPressed += HandleBackspace; + _gamepadController.YPressed += HandleReset; + // 已由 D‑Pad (`LeftPressed` / `RightPressed`) 處理游標移動,移除 LT/RT 綁定以避免語意重複。 + _gamepadController.ConnectionChanged += HandleGamepadConnectionChanged; + } + } + } + + /// + /// 取消訂閱控制器事件 + /// + private void UnsubscribeGamepadEvents() + { + try + { + if (_gamepadController != null) + { + _gamepadController.UpPressed -= HandlePlus; + _gamepadController.UpRepeat -= HandlePlus; + _gamepadController.DownPressed -= HandleMinus; + _gamepadController.DownRepeat -= HandleMinus; + _gamepadController.LeftPressed -= HandleLeft; + _gamepadController.LeftRepeat -= HandleLeft; + _gamepadController.RightPressed -= HandleRight; + _gamepadController.RightRepeat -= HandleRight; + _gamepadController.RSLeftPressed -= HandleRSLeft; + _gamepadController.RSLeftRepeat -= HandleRSLeft; + _gamepadController.RSRightPressed -= HandleRSRight; + _gamepadController.RSRightRepeat -= HandleRSRight; + _gamepadController.APressed -= HandleGamepadA; + _gamepadController.APressed -= HandleCancel; + _gamepadController.StartPressed -= HandleOpenTouchKeyboardFromGamepad; + _gamepadController.BPressed -= HandleGamepadA; + _gamepadController.BPressed -= HandleCancel; + _gamepadController.BackPressed -= HandleCancel; + _gamepadController.XPressed -= HandleBackspace; + _gamepadController.YPressed -= HandleReset; + _gamepadController.ConnectionChanged -= HandleGamepadConnectionChanged; + } + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] 取消訂閱控制器事件失敗:{ex.Message}"); + } + } + + /// + /// 處理控制器連線狀態變更 + /// + /// 是否已連線 + private void HandleGamepadConnectionChanged(bool connected) + { + try + { + if (connected) + { + _gamepadController?.Resume(); + } + + // 告知使用者控制器連線狀態變更。 + AnnounceA11y(connected ? + string.Format(Strings.A11y_Gamepad_Connected, _gamepadController?.DeviceName) : + string.Format(Strings.A11y_Gamepad_Disconnected, _gamepadController?.DeviceName)); + } + catch (Exception ex) + { + LoggerService.LogException(ex, "NumericInputDialog.HandleGamepadConnectionChanged 失敗"); + + Debug.WriteLine($"[NumericInputDialog] 控制器連線變更處理失敗:{ex.Message}"); + } + } + + /// + /// 處理邊界撞擊效果 + /// + /// 是否為上限 + internal void HandleBoundaryHit(bool isUpperLimit) + { + this.SafeInvoke(() => + { + try + { + // 利用 _isFlashing 作為防呆機制,避免控制器長按連發時造成音效與語音播報卡頓。 + if (_isFlashing == 0) + { + FeedbackService.PlaySound(SystemSounds.Beep); + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.ActionFail, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + + AnnounceA11y(isUpperLimit ? + Strings.A11y_Value_Max : + Strings.A11y_Value_Min, true); + + FlashAlertAsync().SafeFireAndForget(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] HandleBoundaryHit 失敗:{ex.Message}"); + } + }); + } + + /// + /// 處理數值增加,並保留焦點以支援眼動儀連發與鍵盤連點 + /// + private void HandlePlus() => this.SafeInvoke(() => + { + try + { + _nud?.UpButton(); + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] HandlePlus 失敗:{ex.Message}"); + } + }); + + /// + /// 處理數值減少,並保留焦點以支援眼動儀連發與鍵盤連點 + /// + private void HandleMinus() => this.SafeInvoke(() => + { + try + { + _nud?.DownButton(); + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] HandleMinus 失敗:{ex.Message}"); + } + }); + + /// + /// 處理游標移動 + /// + /// 是否向右(前進)移動 + private void MoveCaret(bool forward) => this.SafeInvoke(() => + { + try + { + if (_nud == null) + { + return; + } + + TextBox? textBox = _nud.Controls.OfType().FirstOrDefault(); + + if (textBox == null || + textBox.IsDisposed) + { + return; + } + + bool hasSelection = textBox.SelectionLength > 0; + + bool canMove = forward ? + (hasSelection || textBox.SelectionStart < textBox.TextLength) : + (hasSelection || textBox.SelectionStart > 0); + + if (canMove) + { + if (hasSelection) + { + if (forward) + { + textBox.SelectionStart += textBox.SelectionLength; + } + + textBox.SelectionLength = 0; + } + // 組合鍵:任一肩鍵 + 方向鍵 執行單字跳轉。 + else if (_gamepadController?.IsLeftShoulderHeld == true || + _gamepadController?.IsRightShoulderHeld == true) + { + textBox.WordJump(forward); + } + else + { + if (forward) + { + textBox.SelectionStart++; + } + else + { + textBox.SelectionStart--; + } + } + + textBox.ScrollToCaret(); + + // 取得目前游標位置(1-based 報讀)。 + int pos = textBox.SelectionStart; + + AnnounceA11y(AppSettings.Current.IsPrivacyMode ? + Strings.A11y_Cursor_Move_PrivacySafe : + string.Format(Strings.A11y_Cursor_Move, pos + 1), true); + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.CursorMove, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + else + { + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.ActionFail, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] MoveCaret 失敗:{ex.Message}"); + } + }); + + /// + /// 處理游標左移 + /// + private void HandleLeft() => MoveCaret(false); + + /// + /// 處理游標右移 + /// + private void HandleRight() => MoveCaret(true); + + /// + /// 處理選取範圍擴張 + /// + /// 是否向右擴張 + private void ExpandSelection(bool forward) => this.SafeInvoke(() => + { + try + { + if (_nud == null) + { + return; + } + + TextBox? textBox = _nud.Controls.OfType().FirstOrDefault(); + + if (textBox == null || + textBox.IsDisposed) + { + return; + } + + // 當目前沒有選取範圍,或是目前的選取範圍與我們的錨點不匹配時,重新設定錨點,並推算活動邊緣。 + (int anchor, int caret) = textBox.ResolveSelectionAnchor(_rsSelectionAnchor); + + _rsSelectionAnchor = anchor; + + int direction = forward ? 1 : -1; + bool wordGranularity = _gamepadController?.IsLeftShoulderHeld == true || + _gamepadController?.IsRightShoulderHeld == true; + + int newCaret = wordGranularity ? + textBox.GetWordJumpTarget(caret, direction > 0) : + Math.Clamp(caret + direction, 0, textBox.TextLength); + + if (newCaret == caret) + { + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.ActionFail, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + + return; + } + + // 使用 Win32 EM_SETSEL 設定選取範圍。 + textBox.SetSelectionWithActiveEdge(anchor, newCaret); + + if (textBox.SelectionLength > 0) + { + AnnounceA11y(AppSettings.Current.IsPrivacyMode ? + Strings.A11y_Selected_Text_PrivacySafe : + string.Format(Strings.A11y_Selected_Text, textBox.SelectedText), true); + } + + PlaySelectionFeedback(direction, wordGranularity); + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] ExpandSelection 失敗:{ex.Message}"); + } + }); + + /// + /// 根據選取粒度與速度播放不同的右搖桿文字選取回饋。 + /// + /// 選取方向;負值為向左,正值為向右。 + /// 是否為單字粒度選取。 + private void PlaySelectionFeedback(int direction, bool wordGranularity) + { + IGamepadController? controller = _gamepadController; + + if (controller == null || + !controller.IsConnected) + { + return; + } + + DateTime now = DateTime.UtcNow; + _selectionFeedbackBurstLevel = wordGranularity ? + 0 : + (now - _lastSelectionFeedbackUtc).TotalMilliseconds <= SelectionBurstWindowMs ? + Math.Min(_selectionFeedbackBurstLevel + 1, 3) : + 0; + _lastSelectionFeedbackUtc = now; + + FeedbackService.VibrateSequenceAsync( + controller, + VibrationPatterns.GetSelectionSequence(direction, wordGranularity, _selectionFeedbackBurstLevel, controller.VibrationMotorSupport), + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + + FeedbackService.PlaySelectionCue(wordGranularity, _selectionFeedbackBurstLevel); + } + + /// + /// 處理選取左向擴張 + /// + private void HandleRSLeft() => ExpandSelection(false); + + /// + /// 處理選取右向擴張 + /// + private void HandleRSRight() => ExpandSelection(true); + + /// + /// 處理刪除按鍵(Backspace 與 Delete)的共用邏輯 + /// + /// 目標 TextBox + /// 是否為 Backspace 鍵 + private void HandleDeleteKey(TextBox textBox, bool isBackspace) + { + try + { + // 擷取刪除前的狀態。 + string oldText = textBox.Text; + + int oldStart = textBox.SelectionStart, + oldLen = textBox.SelectionLength; + + // 檢查是否可以刪除。 + bool cannotDelete = isBackspace ? + (oldLen == 0 && oldStart == 0) : + (oldLen == 0 && oldStart == oldText.Length); + + if (cannotDelete) + { + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.ActionFail, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + + if (isBackspace) + { + AnnounceA11y(Strings.A11y_Cannot_Delete, true); + } + + return; + } + + this.SafeBeginInvoke(() => + { + try + { + if (textBox.IsDisposed) + { + return; + } + + // 比對內容是否真的減少。 + if (textBox.TextLength < oldText.Length) + { + if (oldLen > 0) + { + AnnounceA11y(string.Format(Strings.A11y_Delete_Multiple, oldLen), true); + } + else + { + int deleteIndex = isBackspace ? + oldStart - 1 : + oldStart; + + if (deleteIndex >= 0 && + deleteIndex < oldText.Length) + { + if (AppSettings.Current.IsPrivacyMode) + { + AnnounceA11y(Strings.A11y_Delete_Char_PrivacySafe, true); + } + else + { + char deletedChar = oldText[deleteIndex]; + + AnnounceA11y(string.Format(Strings.A11y_Delete_Char, deletedChar), true); + } + } + } + } + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] HandleDeleteKey 延遲邏輯失敗:{ex.Message}"); + } + }); + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] HandleDeleteKey 失敗:{ex.Message}"); + } + } + + /// + /// A 鍵:按鈕 → PerformClick;輸入框為空時開啟觸控鍵盤;其餘情境走確認驗證。 + /// + private void HandleGamepadA() => this.SafeInvoke(() => + { + try + { + if (ActiveControl is Button btn) + { + btn.PerformClick(); + return; + } + + if (_nud != null && (_nud.Focused || _nud.ContainsFocus)) + { + TextBox? tb = _nud.Controls.OfType().FirstOrDefault(); + if (tb != null && string.IsNullOrWhiteSpace(tb.Text)) + { + ShowTouchKeyboard(tb); + return; + } + } + + HandleConfirm(); + } + catch (Exception ex) { Debug.WriteLine($"[NumericInputDialog] HandleGamepadA 失敗: {ex.Message}"); } + }); + + /// + /// Start 鍵:在輸入框焦點時直接開啟觸控式鍵盤 + /// + private void HandleOpenTouchKeyboardFromGamepad() => this.SafeInvoke(() => + { + try + { + TextBox? tb = _nud?.Controls.OfType().FirstOrDefault(); + if (tb != null) + { + ShowTouchKeyboard(tb); + } + } + catch (Exception ex) { Debug.WriteLine($"[NumericInputDialog] HandleOpenTouchKeyboardFromGamepad 失敗: {ex.Message}"); } + }); + + /// + /// 聚焦指定輸入框並非同步開啟觸控式鍵盤。 + /// + /// 要聚焦的輸入框。 + private void ShowTouchKeyboard(TextBox tb) + { + if (tb.CanFocus && + !tb.Focused) + { + tb.Focus(); + } + + AnnounceA11y(Strings.A11y_Opening_Keyboard, interrupt: true); + + Task.Run(async () => + { + try + { + await Task.Delay(150, _cts?.Token ?? CancellationToken.None); + + if (TouchKeyboardService.IsVisible()) return; + + await this.SafeInvokeAsync(() => + { + try + { + bool opened = TouchKeyboardService.TryOpen(); + + if (opened) + { + FeedbackService.PlaySound(SystemSounds.Asterisk); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] ShowTouchKeyboard 內層失敗: {ex.Message}"); + } + }); + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] ShowTouchKeyboard 背景工作失敗: {ex.Message}"); + } + }, _cts?.Token ?? CancellationToken.None).SafeFireAndForget(); + } + + /// + /// X 鍵:刪除選取文字或游標前一字元 + /// + private void HandleBackspace() => this.SafeInvoke(() => + { + try + { + if (_nud == null) + { + return; + } + + TextBox? tb = _nud.Controls.OfType().FirstOrDefault(); + + if (tb == null || + tb.IsDisposed || + tb.ReadOnly) + { + return; + } + + // 實施手動字串處理以符合「不模擬按鍵」的安全性紅線。 + // 透過直接操作 TextBox.Text,能觸發 NumericUpDown 的內部驗證機制且不依賴 Win32 訊息注入。 + if (tb.SelectionLength > 0) + { + tb.SelectedText = string.Empty; + } + else if (tb.SelectionStart > 0) + { + int start = tb.SelectionStart; + + tb.Text = tb.Text.Remove(start - 1, 1); + tb.SelectionStart = start - 1; + } + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] HandleBackspace 失敗: {ex.Message}"); + } + }); + + /// + /// LT 鍵:向左導覽游標 + /// + private void HandleLTNav() => MoveCaret(false); + + /// + /// RT 鍵:向右導覽游標 + /// + private void HandleRTNav() => MoveCaret(true); +} diff --git a/src/InputBox/Core/Controls/NumericInputDialog.Layout.cs b/src/InputBox/Core/Controls/NumericInputDialog.Layout.cs new file mode 100644 index 0000000..546e57f --- /dev/null +++ b/src/InputBox/Core/Controls/NumericInputDialog.Layout.cs @@ -0,0 +1,442 @@ +using InputBox.Core.Configuration; +using InputBox.Core.Extensions; +using InputBox.Core.Interop; +using InputBox.Core.Services; +using InputBox.Core.Utilities; +using InputBox.Resources; +using Microsoft.Win32; +using System.Diagnostics; + +namespace InputBox.Core.Controls; + +// 阻擋設計工具。 +partial class DesignerBlocker { }; + +/// +/// 專門用於數值輸入的對話框(版面配置與視覺分部)。 +/// 本分部檔案包含 DPI 與系統偏好變更處理、最小尺寸與按鈕約束、智慧定位、透明度,以及焦點與游標視覺等成員。 +/// +internal sealed partial class NumericInputDialog +{ + protected override void OnResizeEnd(EventArgs e) + { + try + { + base.OnResizeEnd(e); + + // 拖曳結束時執行智慧定位修正。 + ApplySmartPosition(); + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] OnResizeEnd 失敗:{ex.Message}"); + } + } + + protected override void OnHandleCreated(EventArgs e) + { + try + { + base.OnHandleCreated(e); + + UpdateMinimumSize(); + + // 使用 SafeBeginInvoke 讓字型替換邏輯排在 Handle 建立完成「之後」才執行。 + this.SafeBeginInvoke(() => + { + try + { + // 套用透明度。 + UpdateOpacity(); + + // 執行初始位置檢查。 + ApplySmartPosition(); + } + catch (Exception ex) + { + LoggerService.LogException(ex, "NumericInputDialog.OnHandleCreated 延遲邏輯失敗"); + + Debug.WriteLine($"[NumericInputDialog] OnHandleCreated 延遲邏輯失敗:{ex.Message}"); + } + }); + + // 先解除再訂閱靜態事件,防止 Handle 重建時產生重複訂閱。 + SystemEvents.UserPreferenceChanged -= SystemEvents_UserPreferenceChanged; + SystemEvents.UserPreferenceChanged += SystemEvents_UserPreferenceChanged; + } + catch (Exception ex) + { + LoggerService.LogException(ex, "NumericInputDialog.OnHandleCreated 失敗"); + + Debug.WriteLine($"[NumericInputDialog] OnHandleCreated 失敗:{ex.Message}"); + } + } + + protected override void OnDpiChanged(DpiChangedEventArgs e) + { + try + { + base.OnDpiChanged(e); + + // 當 DPI 變更時,強制全視窗重新讀取全域共享字體。 + this.SafeInvoke(() => + { + try + { + // 重新取得 A11y 字型實例(從快取池取得)。 + _a11yFont = MainForm.GetSharedA11yFont(DeviceDpi); + + // 同步更新所有按鈕的基礎字體。 + if (_a11yFont != null) + { + _btnOk!.Font = _a11yFont; + _btnCancel!.Font = _a11yFont; + _btnPlus!.Font = _a11yFont; + _btnMinus!.Font = _a11yFont; + _btnReset!.Font = _a11yFont; + } + + // 數值顯示區字體需重新依據新縮放比例建立。 + if (_a11yFont != null && + _nud != null) + { + // 2.0x 放大字體(來自共享快取,不需手動回收)。 + _nudFont = MainForm.GetSharedA11yFont(DeviceDpi, FontStyle.Bold, _a11yFont.FontFamily, 2.0f); + + _nud.Font = _nudFont; + } + + // 更新佈局約束。 + UpdateMinimumSize(); + + // 強制所有控制項重新套用最新主題與字體。 + UpdateFocusVisuals(_nud?.Focused == true || (_nud?.ContainsFocus == true)); + + ApplySmartPosition(); + } + catch (Exception ex) + { + LoggerService.LogException(ex, "NumericInputDialog.OnDpiChanged 延遲邏輯失敗"); + + Debug.WriteLine($"[NumericInputDialog] OnDpiChanged 延遲邏輯失敗:{ex.Message}"); + } + }); + } + catch (Exception ex) + { + LoggerService.LogException(ex, "NumericInputDialog.OnDpiChanged 失敗"); + + Debug.WriteLine($"[NumericInputDialog] OnDpiChanged 失敗:{ex.Message}"); + } + } + + private void SystemEvents_UserPreferenceChanged(object sender, UserPreferenceChangedEventArgs e) + { + try + { + if (e.Category == UserPreferenceCategory.Accessibility || + e.Category == UserPreferenceCategory.Color || + e.Category == UserPreferenceCategory.General) + { + this.SafeInvoke(() => + { + try + { + UpdateMinimumSize(); + + UpdateFocusVisuals(_nud?.Focused == true || (_nud?.ContainsFocus == true)); + } + catch (Exception ex) + { + LoggerService.LogException(ex, "[NumericInputDialog] SystemEvents 更新失敗"); + + Debug.WriteLine($"[NumericInputDialog] SystemEvents 更新失敗:{ex.Message}"); + } + }); + } + } + catch (Exception ex) + { + LoggerService.LogException(ex, "[NumericInputDialog] SystemEvents 處理失敗"); + + Debug.WriteLine($"[NumericInputDialog] SystemEvents 處理失敗:{ex.Message}"); + } + } + + protected override void OnHandleDestroyed(EventArgs e) + { + try + { + // 確保靜態事件在視窗控制項控制代碼銷毀時被絕對釋放。 + SystemEvents.UserPreferenceChanged -= SystemEvents_UserPreferenceChanged; + } + finally + { + base.OnHandleDestroyed(e); + } + } + + /// + /// 更新控制項焦點狀態的視覺表現 + /// + /// 指示控制項是否具有焦點 + private void UpdateFocusVisuals(bool isFocused) + { + try + { + if (_nud == null || + _nud.IsDisposed) + { + return; + } + + // 綜合判斷:只要數值框本身有焦點,或是旁邊的加減按鈕有焦點,數值框都應該保持高亮。 + bool shouldHighlight = isFocused || + (_btnPlus != null && _btnPlus.Focused) || + (_btnMinus != null && _btnMinus.Focused); + + if (shouldHighlight) + { + if (SystemInformation.HighContrast) + { + _nud.UpdateRecursive(SystemColors.Highlight, SystemColors.HighlightText); + } + else + { + bool isDark = this.IsDarkModeActive(); + + // 淺色模式:黑底白字、深色模式:白底黑字。 + _nud.UpdateRecursive( + isDark ? Color.White : Color.Black, + isDark ? Color.Black : Color.White); + } + } + else + { + _nud.ResetThemeRecursive(); + } + + // 當具有焦點時,強化游標。 + if (isFocused) + { + UpdateCaretWidth(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] UpdateFocusVisuals 失敗:{ex.Message}"); + } + } + + /// + /// 根據 DPI 與無障礙設定更新數值框內部游標寬度 + /// + private void UpdateCaretWidth() + { + try + { + if (_nud == null || + _nud.IsDisposed || + !_nud.IsHandleCreated) + { + return; + } + + // 尋找 NUD 內部的 TextBox 子控制項。 + TextBox? innerTextBox = _nud.Controls.OfType().FirstOrDefault(); + + if (innerTextBox == null || + !innerTextBox.IsHandleCreated) + { + return; + } + + // 基礎寬度 3px,隨 DPI 縮放。 + float scale = DeviceDpi / AppSettings.BaseDpi; + + int caretWidth = (int)Math.Max(3, 3 * scale); + + // 高對比模式下額外加粗。 + if (SystemInformation.HighContrast) + { + caretWidth += (int)(2 * scale); + } + + int caretHeight = innerTextBox.Height; + + // 若寬高與上次一致,則略過 Win32 API 調用,減少 UI 閃爍感。 + if (caretWidth == _lastCaretWidth && + caretHeight == _lastCaretHeight) + { + return; + } + + _lastCaretWidth = caretWidth; + _lastCaretHeight = caretHeight; + + // 使用 Win32 API 重新建立游標。 + if (User32.CreateCaret(innerTextBox.Handle, IntPtr.Zero, caretWidth, caretHeight)) + { + User32.ShowCaret(innerTextBox.Handle); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] UpdateCaretWidth 失敗:{ex.Message}"); + } + } + + /// + /// 更新視窗不透明度。 + /// + private void UpdateOpacity() + { + try + { + // 根據規範,若系統開啟高對比模式,則強制為 1.0 以確保絕對可讀性。 + if (SystemInformation.HighContrast) + { + Opacity = 1.0; + + return; + } + + // 數值輸入框鎖定 1.0 不透明度以確保輸入清晰度。 + Opacity = 1.0; + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] UpdateOpacity 失敗:{ex.Message}"); + } + } + + /// + /// 更新視窗最小尺寸與按鈕佈局約束 + /// + private void UpdateMinimumSize() + { + try + { + float currentDpi = DeviceDpi; + + if (!DialogLayoutHelper.TryBeginDpiLayout(currentDpi, ref _lastAppliedDpi)) + { + return; + } + + float scale = currentDpi / AppSettings.BaseDpi; + + // 內容感知:更新所有按鈕的佈局約束(Bold 預測)。 + // 確保按鈕在獲得焦點變為粗體時,物理邊界保持絕對靜止,達成 Zero-Jitter。 + UpdateButtonConstraints(_btnOk, scale); + UpdateButtonConstraints(_btnCancel, scale); + UpdateButtonConstraints(_btnPlus, scale); + UpdateButtonConstraints(_btnMinus, scale); + UpdateButtonConstraints(_btnReset, scale); + + // 改用內容偏好尺寸作為基準。 + _tlpGrid?.PerformLayout(); + + Size contentPref = _tlpGrid?.GetPreferredSize(Size.Empty) ?? + new((int)(450 * scale), (int)(250 * scale)); + + Rectangle workArea = Screen.GetWorkingArea(this); + + // 計算邊框與標題列所需的額外空間(比照 HelpDialog.cs)。 + int frameW = SystemInformation.FrameBorderSize.Width * 2, + frameH = SystemInformation.FrameBorderSize.Height * 2, + captionH = SystemInformation.CaptionHeight; + + (int maxFitW, int maxFitH) = DialogLayoutHelper.GetMaxFitSize(workArea); + + // 視窗寬度:內容寬度 + 表單 Padding + 框架,以工作區上限為準。 + int formW = Math.Clamp( + contentPref.Width + Padding.Horizontal + frameW + 8, + (int)(450 * scale), + maxFitW); + + // 視窗高度:依實際內容偏好尺寸計算,上限為工作區可用高度(保留 40px 邊界)。 + int desiredMinHeight = (int)(300 * scale), + naturalH = contentPref.Height + Padding.Vertical + captionH + frameH + 8, + formH = Math.Clamp(naturalH, desiredMinHeight, Math.Max(desiredMinHeight, maxFitH)); + + MinimumSize = new Size(Math.Min((int)(450 * scale), maxFitW), desiredMinHeight); + + Size = new Size(formW, formH); + + // 佈局擴張後,執行智慧定位檢查。 + ApplySmartPosition(); + + // 高對比模式下強制 100% 不透明度。 + if (SystemInformation.HighContrast) + { + UpdateOpacity(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] UpdateMinimumSize 失敗:{ex.Message}"); + } + } + + /// + /// 更新單個按鈕的佈局約束與最小尺寸鎖定 + /// + /// 目標按鈕 + /// 目前 DPI 縮放比例 + private void UpdateButtonConstraints(Button? btn, float scale) + { + try + { + if (btn == null || + btn.IsDisposed) + { + return; + } + + // 取得專屬於此視窗 DPI 的 Bold 字體實例。 + Font boldFont = MainForm.GetSharedA11yFont(DeviceDpi, FontStyle.Bold, btn.Font.FontFamily); + + // 眼動儀友善:抗抖動寬度鎖定(Anti-Jitter Lock)。 + DialogLayoutHelper.UpdateButtonMinimumSize(btn, boldFont, scale, 120, 60, 32, 24); + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] UpdateButtonConstraints 失敗:{ex.Message}"); + } + } + + /// + /// 執行智慧定位修正,確保視窗不會跑出螢幕邊界 + /// + private void ApplySmartPosition() + { + // 脫離目前的佈局計算循環,確保所有 StartPosition 與 AutoSize 已處理完畢。 + this.SafeBeginInvoke(() => + { + try + { + if (IsDisposed || + !IsHandleCreated) + { + return; + } + + // 強制同步最新的實體佈局尺寸(關鍵:確保 Width/Height 是縮放後的真實值)。 + PerformLayout(); + + if (InputBoxLayoutManager.TryGetClampedLocation(this, out Point clampedLocation)) + { + Location = clampedLocation; + + // 告知使用者視窗已修正位置。 + AnnounceA11y(Strings.A11y_SnapBack); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[NumericInputDialog] ApplySmartPosition 失敗:{ex.Message}"); + } + }); + } +} diff --git a/src/InputBox/Core/Controls/NumericInputDialog.cs b/src/InputBox/Core/Controls/NumericInputDialog.cs index b18fdb9..723d35d 100644 --- a/src/InputBox/Core/Controls/NumericInputDialog.cs +++ b/src/InputBox/Core/Controls/NumericInputDialog.cs @@ -20,125 +20,8 @@ partial class DesignerBlocker { }; /// /// 專門用於數值輸入的對話框 /// -internal sealed class NumericInputDialog : Form +internal sealed partial class NumericInputDialog : Form { - /// - /// 繼承自 NumericUpDown 以公開受保護的成員方法 - /// - /// 父對話框實例 - private sealed class AccessibleNumericUpDown(NumericInputDialog parent) : NumericUpDown - { - /// - /// 父 NumericInputDialog 實例,用於回呼邊界撞牆通知。 - /// - private readonly NumericInputDialog _parent = parent; - - /// - /// 遞增數值,若已達上限則通知父對話框播放邊界回饋。 - /// - public override void UpButton() - { - try - { - decimal oldValue = Value; - - base.UpButton(); - - if (Value == oldValue) - { - _parent.HandleBoundaryHit(true); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericUpDown] UpButton 失敗:{ex.Message}"); - } - } - - /// - /// 遞減數值,若已達下限則通知父對話框播放邊界回饋。 - /// - public override void DownButton() - { - try - { - decimal oldValue = Value; - - base.DownButton(); - - if (Value == oldValue) - { - _parent.HandleBoundaryHit(false); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericUpDown] DownButton 失敗:{ex.Message}"); - } - } - - /// - /// 主動觸發無障礙狀態變更通知 - /// - public void NotifyAccessibilityChange() - { - try - { - // 主動通知輔助科技(AT)數值已變更。 - AccessibilityNotifyClients(AccessibleEvents.ValueChange, -1); - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericUpDown] NotifyAccessibilityChange 失敗:{ex.Message}"); - } - } - - /// - /// 強制驗證編輯文字,確保 Value 屬性與目前輸入內容同步 - /// - public void ValidateValue() - { - try - { - ValidateEditText(); - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericUpDown] ValidateValue 失敗:{ex.Message}"); - } - } - - /// - /// 覆寫滑鼠滾輪行為,確保「一格跳一格」 - /// - /// MouseEventArgs - protected override void OnMouseWheel(MouseEventArgs e) - { - try - { - // 強制攔截並阻斷 Windows 系統的「一次捲動多行」設定(預設為 3)。 - if (e is HandledMouseEventArgs hme) - { - hme.Handled = true; - } - - // 手動精確執行單次增減,不調用 base.OnMouseWheel(e)。 - if (e.Delta > 0) - { - UpButton(); - } - else if (e.Delta < 0) - { - DownButton(); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericUpDown] OnMouseWheel 失敗:{ex.Message}"); - } - } - } - /// /// 預設數值 /// @@ -262,214 +145,6 @@ protected override void OnMouseWheel(MouseEventArgs e) [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] public Func? ConfirmBeforeClose { get; set; } - /// - /// 設定控制器實作,並訂閱事件 - /// - [Browsable(false)] - [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] - public IGamepadController? GamepadController - { - get => _gamepadController; - set - { - // 如果控制器相同,不執行任何操作。 - if (ReferenceEquals(_gamepadController, value)) - { - return; - } - - // 安全清理舊控制器的訂閱。 - UnsubscribeGamepadEvents(); - - _gamepadController = value; - - if (_gamepadController != null) - { - GamepadFaceButtonProfile profile = GamepadFaceButtonProfile.GetActiveProfile(); - - // 訂閱新控制器事件。 - _gamepadController.UpPressed += HandlePlus; - _gamepadController.UpRepeat += HandlePlus; - _gamepadController.DownPressed += HandleMinus; - _gamepadController.DownRepeat += HandleMinus; - _gamepadController.LeftPressed += HandleLeft; - _gamepadController.LeftRepeat += HandleLeft; - _gamepadController.RightPressed += HandleRight; - _gamepadController.RightRepeat += HandleRight; - _gamepadController.RSLeftPressed += HandleRSLeft; - _gamepadController.RSLeftRepeat += HandleRSLeft; - _gamepadController.RSRightPressed += HandleRSRight; - _gamepadController.RSRightRepeat += HandleRSRight; - _gamepadController.APressed += profile.ConfirmOnSouth ? HandleGamepadA : HandleCancel; - _gamepadController.StartPressed += HandleOpenTouchKeyboardFromGamepad; - _gamepadController.BPressed += profile.ConfirmOnSouth ? HandleCancel : HandleGamepadA; - _gamepadController.BackPressed += HandleCancel; - _gamepadController.XPressed += HandleBackspace; - _gamepadController.YPressed += HandleReset; - // 已由 D‑Pad (`LeftPressed` / `RightPressed`) 處理游標移動,移除 LT/RT 綁定以避免語意重複。 - _gamepadController.ConnectionChanged += HandleGamepadConnectionChanged; - } - } - } - - protected override void OnResizeEnd(EventArgs e) - { - try - { - base.OnResizeEnd(e); - - // 拖曳結束時執行智慧定位修正。 - ApplySmartPosition(); - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] OnResizeEnd 失敗:{ex.Message}"); - } - } - - protected override void OnHandleCreated(EventArgs e) - { - try - { - base.OnHandleCreated(e); - - UpdateMinimumSize(); - - // 使用 SafeBeginInvoke 讓字型替換邏輯排在 Handle 建立完成「之後」才執行。 - this.SafeBeginInvoke(() => - { - try - { - // 套用透明度。 - UpdateOpacity(); - - // 執行初始位置檢查。 - ApplySmartPosition(); - } - catch (Exception ex) - { - LoggerService.LogException(ex, "NumericInputDialog.OnHandleCreated 延遲邏輯失敗"); - - Debug.WriteLine($"[NumericInputDialog] OnHandleCreated 延遲邏輯失敗:{ex.Message}"); - } - }); - - // 先解除再訂閱靜態事件,防止 Handle 重建時產生重複訂閱。 - SystemEvents.UserPreferenceChanged -= SystemEvents_UserPreferenceChanged; - SystemEvents.UserPreferenceChanged += SystemEvents_UserPreferenceChanged; - } - catch (Exception ex) - { - LoggerService.LogException(ex, "NumericInputDialog.OnHandleCreated 失敗"); - - Debug.WriteLine($"[NumericInputDialog] OnHandleCreated 失敗:{ex.Message}"); - } - } - - protected override void OnDpiChanged(DpiChangedEventArgs e) - { - try - { - base.OnDpiChanged(e); - - // 當 DPI 變更時,強制全視窗重新讀取全域共享字體。 - this.SafeInvoke(() => - { - try - { - // 重新取得 A11y 字型實例(從快取池取得)。 - _a11yFont = MainForm.GetSharedA11yFont(DeviceDpi); - - // 同步更新所有按鈕的基礎字體。 - if (_a11yFont != null) - { - _btnOk!.Font = _a11yFont; - _btnCancel!.Font = _a11yFont; - _btnPlus!.Font = _a11yFont; - _btnMinus!.Font = _a11yFont; - _btnReset!.Font = _a11yFont; - } - - // 數值顯示區字體需重新依據新縮放比例建立。 - if (_a11yFont != null && - _nud != null) - { - // 2.0x 放大字體(來自共享快取,不需手動回收)。 - _nudFont = MainForm.GetSharedA11yFont(DeviceDpi, FontStyle.Bold, _a11yFont.FontFamily, 2.0f); - - _nud.Font = _nudFont; - } - - // 更新佈局約束。 - UpdateMinimumSize(); - - // 強制所有控制項重新套用最新主題與字體。 - UpdateFocusVisuals(_nud?.Focused == true || (_nud?.ContainsFocus == true)); - - ApplySmartPosition(); - } - catch (Exception ex) - { - LoggerService.LogException(ex, "NumericInputDialog.OnDpiChanged 延遲邏輯失敗"); - - Debug.WriteLine($"[NumericInputDialog] OnDpiChanged 延遲邏輯失敗:{ex.Message}"); - } - }); - } - catch (Exception ex) - { - LoggerService.LogException(ex, "NumericInputDialog.OnDpiChanged 失敗"); - - Debug.WriteLine($"[NumericInputDialog] OnDpiChanged 失敗:{ex.Message}"); - } - } - - private void SystemEvents_UserPreferenceChanged(object sender, UserPreferenceChangedEventArgs e) - { - try - { - if (e.Category == UserPreferenceCategory.Accessibility || - e.Category == UserPreferenceCategory.Color || - e.Category == UserPreferenceCategory.General) - { - this.SafeInvoke(() => - { - try - { - UpdateMinimumSize(); - - UpdateFocusVisuals(_nud?.Focused == true || (_nud?.ContainsFocus == true)); - } - catch (Exception ex) - { - LoggerService.LogException(ex, "[NumericInputDialog] SystemEvents 更新失敗"); - - Debug.WriteLine($"[NumericInputDialog] SystemEvents 更新失敗:{ex.Message}"); - } - }); - } - } - catch (Exception ex) - { - LoggerService.LogException(ex, "[NumericInputDialog] SystemEvents 處理失敗"); - - Debug.WriteLine($"[NumericInputDialog] SystemEvents 處理失敗:{ex.Message}"); - } - } - - protected override void OnHandleDestroyed(EventArgs e) - { - try - { - // 確保靜態事件在視窗控制項控制代碼銷毀時被絕對釋放。 - SystemEvents.UserPreferenceChanged -= SystemEvents_UserPreferenceChanged; - } - finally - { - base.OnHandleDestroyed(e); - } - } - protected override void Dispose(bool disposing) { try @@ -510,1187 +185,123 @@ protected override void Dispose(bool disposing) } /// - /// 取消訂閱控制器事件 + /// 處理確認按鍵事件 /// - private void UnsubscribeGamepadEvents() + private void HandleConfirm() => this.SafeInvoke(() => { try { - if (_gamepadController != null) + if (IsDisposed || + !IsHandleCreated) { - _gamepadController.UpPressed -= HandlePlus; - _gamepadController.UpRepeat -= HandlePlus; - _gamepadController.DownPressed -= HandleMinus; - _gamepadController.DownRepeat -= HandleMinus; - _gamepadController.LeftPressed -= HandleLeft; - _gamepadController.LeftRepeat -= HandleLeft; - _gamepadController.RightPressed -= HandleRight; - _gamepadController.RightRepeat -= HandleRight; - _gamepadController.RSLeftPressed -= HandleRSLeft; - _gamepadController.RSLeftRepeat -= HandleRSLeft; - _gamepadController.RSRightPressed -= HandleRSRight; - _gamepadController.RSRightRepeat -= HandleRSRight; - _gamepadController.APressed -= HandleGamepadA; - _gamepadController.APressed -= HandleCancel; - _gamepadController.StartPressed -= HandleOpenTouchKeyboardFromGamepad; - _gamepadController.BPressed -= HandleGamepadA; - _gamepadController.BPressed -= HandleCancel; - _gamepadController.BackPressed -= HandleCancel; - _gamepadController.XPressed -= HandleBackspace; - _gamepadController.YPressed -= HandleReset; - _gamepadController.ConnectionChanged -= HandleGamepadConnectionChanged; + return; } - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] 取消訂閱控制器事件失敗:{ex.Message}"); - } - } - /// - /// 處理控制器連線狀態變更 - /// - /// 是否已連線 - private void HandleGamepadConnectionChanged(bool connected) - { - try - { - if (connected) + _nud?.ValidateValue(); + + // 關閉前執行呼叫端的驗證回呼(例如低不透明度警告)。 + if (ConfirmBeforeClose != null && !ConfirmBeforeClose(Value)) { - _gamepadController?.Resume(); + // 使用者在警告對話框中取消:保持 NumericInputDialog 開啟。 + return; } - // 告知使用者控制器連線狀態變更。 - AnnounceA11y(connected ? - string.Format(Strings.A11y_Gamepad_Connected, _gamepadController?.DeviceName) : - string.Format(Strings.A11y_Gamepad_Disconnected, _gamepadController?.DeviceName)); + // 發送與控制器對等的震動與 A11y 播報。 + FeedbackService.PlaySound(SystemSounds.Asterisk); + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.CopySuccess, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + + // 直接在 UI 執行緒同步播報,避免 Task.Run 路徑在 Dispose 取消 + // _cts 後因 OperationCanceledException 而遺失此關鍵訊息。 + _announcer?.Announce(Strings.A11y_Returning, false); + + DialogResult = DialogResult.OK; + + Close(); } catch (Exception ex) { - LoggerService.LogException(ex, "NumericInputDialog.HandleGamepadConnectionChanged 失敗"); - - Debug.WriteLine($"[NumericInputDialog] 控制器連線變更處理失敗:{ex.Message}"); + Debug.WriteLine($"[NumericInputDialog] HandleConfirm 失敗:{ex.Message}"); } - } + }); /// - /// 處理邊界撞擊效果 + /// 處理取消按鍵事件 /// - /// 是否為上限 - internal void HandleBoundaryHit(bool isUpperLimit) + private void HandleCancel() => this.SafeInvoke(() => { - this.SafeInvoke(() => + try { - try + if (IsDisposed || + !IsHandleCreated) { - // 利用 _isFlashing 作為防呆機制,避免控制器長按連發時造成音效與語音播報卡頓。 - if (_isFlashing == 0) - { - FeedbackService.PlaySound(SystemSounds.Beep); + return; + } - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.ActionFail, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); + // 發送與控制器對等的震動(比照返回動作)。 + FeedbackService.PlaySound(SystemSounds.Exclamation); - AnnounceA11y(isUpperLimit ? - Strings.A11y_Value_Max : - Strings.A11y_Value_Min, true); + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.ReturnStart, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); - FlashAlertAsync().SafeFireAndForget(); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] HandleBoundaryHit 失敗:{ex.Message}"); - } - }); - } + // A11y 播報在 FormClosing 中已處理。 - /// - /// 處理數值增加,並保留焦點以支援眼動儀連發與鍵盤連點 - /// - private void HandlePlus() => this.SafeInvoke(() => - { - try - { - _nud?.UpButton(); + DialogResult = DialogResult.Cancel; + + Close(); } catch (Exception ex) { - Debug.WriteLine($"[NumericInputDialog] HandlePlus 失敗:{ex.Message}"); + Debug.WriteLine($"[NumericInputDialog] HandleCancel 失敗:{ex.Message}"); } }); /// - /// 處理數值減少,並保留焦點以支援眼動儀連發與鍵盤連點 + /// 處理重設按鍵事件 /// - private void HandleMinus() => this.SafeInvoke(() => + private void HandleReset() => this.SafeInvoke(() => { try { - _nud?.DownButton(); - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] HandleMinus 失敗:{ex.Message}"); - } - }); - - /// - /// 處理游標移動 - /// - /// 是否向右(前進)移動 - private void MoveCaret(bool forward) => this.SafeInvoke(() => - { - try - { - if (_nud == null) - { - return; - } - - TextBox? textBox = _nud.Controls.OfType().FirstOrDefault(); - - if (textBox == null || - textBox.IsDisposed) - { - return; - } - - bool hasSelection = textBox.SelectionLength > 0; - - bool canMove = forward ? - (hasSelection || textBox.SelectionStart < textBox.TextLength) : - (hasSelection || textBox.SelectionStart > 0); - - if (canMove) - { - if (hasSelection) - { - if (forward) - { - textBox.SelectionStart += textBox.SelectionLength; - } - - textBox.SelectionLength = 0; - } - // 組合鍵:任一肩鍵 + 方向鍵 執行單字跳轉。 - else if (_gamepadController?.IsLeftShoulderHeld == true || - _gamepadController?.IsRightShoulderHeld == true) - { - textBox.WordJump(forward); - } - else - { - if (forward) - { - textBox.SelectionStart++; - } - else - { - textBox.SelectionStart--; - } - } - - textBox.ScrollToCaret(); - - // 取得目前游標位置(1-based 報讀)。 - int pos = textBox.SelectionStart; - - AnnounceA11y(AppSettings.Current.IsPrivacyMode ? - Strings.A11y_Cursor_Move_PrivacySafe : - string.Format(Strings.A11y_Cursor_Move, pos + 1), true); - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.CursorMove, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - else - { - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.ActionFail, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] MoveCaret 失敗:{ex.Message}"); - } - }); - - /// - /// 處理游標左移 - /// - private void HandleLeft() => MoveCaret(false); - - /// - /// 處理游標右移 - /// - private void HandleRight() => MoveCaret(true); - - /// - /// 處理選取範圍擴張 - /// - /// 是否向右擴張 - private void ExpandSelection(bool forward) => this.SafeInvoke(() => - { - try - { - if (_nud == null) - { - return; - } - - TextBox? textBox = _nud.Controls.OfType().FirstOrDefault(); - - if (textBox == null || - textBox.IsDisposed) - { - return; - } - - // 當目前沒有選取範圍,或是目前的選取範圍與我們的錨點不匹配時,重新設定錨點。 - if (textBox.SelectionLength == 0 || - _rsSelectionAnchor == null || - (textBox.SelectionStart != _rsSelectionAnchor.Value && - textBox.SelectionStart + textBox.SelectionLength != _rsSelectionAnchor.Value)) - { - _rsSelectionAnchor = textBox.SelectionStart; - } - - int anchor = _rsSelectionAnchor.Value; - - // 推算活動邊緣。 - int caret = (textBox.SelectionStart == anchor) ? - (anchor + textBox.SelectionLength) : - textBox.SelectionStart; - - int direction = forward ? 1 : -1; - bool wordGranularity = _gamepadController?.IsLeftShoulderHeld == true || - _gamepadController?.IsRightShoulderHeld == true; - - int newCaret = wordGranularity ? - GetWordSelectionCaretTarget(textBox, caret, direction) : - Math.Clamp(caret + direction, 0, textBox.TextLength); - - if (newCaret == caret) - { - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.ActionFail, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - - return; - } - - // 使用 Win32 EM_SETSEL 設定選取範圍。 - User32.SendMessage(textBox.Handle, (uint)User32.WindowMessage.EM_SETSEL, anchor, newCaret); - textBox.ScrollToCaret(); - - if (textBox.SelectionLength > 0) - { - AnnounceA11y(AppSettings.Current.IsPrivacyMode ? - Strings.A11y_Selected_Text_PrivacySafe : - string.Format(Strings.A11y_Selected_Text, textBox.SelectedText), true); - } - - PlaySelectionFeedback(direction, wordGranularity); - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] ExpandSelection 失敗:{ex.Message}"); - } - }); - - /// - /// 根據選取粒度與速度播放不同的右搖桿文字選取回饋。 - /// - /// 選取方向;負值為向左,正值為向右。 - /// 是否為單字粒度選取。 - private void PlaySelectionFeedback(int direction, bool wordGranularity) - { - IGamepadController? controller = _gamepadController; - - if (controller == null || - !controller.IsConnected) - { - return; - } - - DateTime now = DateTime.UtcNow; - _selectionFeedbackBurstLevel = wordGranularity ? - 0 : - (now - _lastSelectionFeedbackUtc).TotalMilliseconds <= SelectionBurstWindowMs ? - Math.Min(_selectionFeedbackBurstLevel + 1, 3) : - 0; - _lastSelectionFeedbackUtc = now; - - FeedbackService.VibrateSequenceAsync( - controller, - VibrationPatterns.GetSelectionSequence(direction, wordGranularity, _selectionFeedbackBurstLevel, controller.VibrationMotorSupport), - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - - FeedbackService.PlaySelectionCue(wordGranularity, _selectionFeedbackBurstLevel); - } - - /// - /// 以現有的單字跳轉邏輯推算右搖桿在單字粒度下的選取目標位置。 - /// - /// 目標 TextBox。 - /// 目前游標位置(字元索引)。 - /// 方向;正值為向右,負值為向左。 - /// 跳轉後的游標目標位置(字元索引)。 - private static int GetWordSelectionCaretTarget(TextBox textBox, int caret, int direction) - { - int originalStart = textBox.SelectionStart; - int originalLength = textBox.SelectionLength; - - try - { - textBox.SelectionStart = Math.Clamp(caret, 0, textBox.TextLength); - textBox.SelectionLength = 0; - textBox.WordJump(direction > 0); - - return textBox.SelectionStart; - } - finally - { - textBox.SelectionStart = originalStart; - textBox.SelectionLength = originalLength; - } - } - - /// - /// 處理選取左向擴張 - /// - private void HandleRSLeft() => ExpandSelection(false); - - /// - /// 處理選取右向擴張 - /// - private void HandleRSRight() => ExpandSelection(true); - - /// - /// 處理刪除按鍵(Backspace 與 Delete)的共用邏輯 - /// - /// 目標 TextBox - /// 是否為 Backspace 鍵 - private void HandleDeleteKey(TextBox textBox, bool isBackspace) - { - try - { - // 擷取刪除前的狀態。 - string oldText = textBox.Text; - - int oldStart = textBox.SelectionStart, - oldLen = textBox.SelectionLength; - - // 檢查是否可以刪除。 - bool cannotDelete = isBackspace ? - (oldLen == 0 && oldStart == 0) : - (oldLen == 0 && oldStart == oldText.Length); - - if (cannotDelete) + if (GamescopeSurfaceRecovery.TryRecoverFromGamepadChord( + this, + RecreateHandle, + _gamepadController, + context: "NumericInputDialog Gamescope surface recovery 失敗")) { - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.ActionFail, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - - if (isBackspace) - { - AnnounceA11y(Strings.A11y_Cannot_Delete, true); - } - return; } - this.SafeBeginInvoke(() => - { - try - { - if (textBox.IsDisposed) - { - return; - } - - // 比對內容是否真的減少。 - if (textBox.TextLength < oldText.Length) - { - if (oldLen > 0) - { - AnnounceA11y(string.Format(Strings.A11y_Delete_Multiple, oldLen), true); - } - else - { - int deleteIndex = isBackspace ? - oldStart - 1 : - oldStart; - - if (deleteIndex >= 0 && - deleteIndex < oldText.Length) - { - if (AppSettings.Current.IsPrivacyMode) - { - AnnounceA11y(Strings.A11y_Delete_Char_PrivacySafe, true); - } - else - { - char deletedChar = oldText[deleteIndex]; - - AnnounceA11y(string.Format(Strings.A11y_Delete_Char, deletedChar), true); - } - } - } - } - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] HandleDeleteKey 延遲邏輯失敗:{ex.Message}"); - } - }); - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] HandleDeleteKey 失敗:{ex.Message}"); - } - } - - /// - /// 處理確認按鍵事件 - /// - private void HandleConfirm() => this.SafeInvoke(() => - { - try - { if (IsDisposed || - !IsHandleCreated) + !IsHandleCreated || + _nud == null) { return; } - _nud?.ValidateValue(); - - // 關閉前執行呼叫端的驗證回呼(例如低不透明度警告)。 - if (ConfirmBeforeClose != null && !ConfirmBeforeClose(Value)) - { - // 使用者在警告對話框中取消:保持 NumericInputDialog 開啟。 - return; - } + _nud.Value = Math.Clamp(_defaultValue, _nud.Minimum, _nud.Maximum); + _nud.Focus(); - // 發送與控制器對等的震動與 A11y 播報。 FeedbackService.PlaySound(SystemSounds.Asterisk); FeedbackService.VibrateAsync( _gamepadController, - VibrationPatterns.CopySuccess, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - - // 直接在 UI 執行緒同步播報,避免 Task.Run 路徑在 Dispose 取消 - // _cts 後因 OperationCanceledException 而遺失此關鍵訊息。 - _announcer?.Announce(Strings.A11y_Returning, false); - - DialogResult = DialogResult.OK; - - Close(); - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] HandleConfirm 失敗:{ex.Message}"); - } - }); - - /// - /// 處理取消按鍵事件 - /// - private void HandleCancel() => this.SafeInvoke(() => - { - try - { - if (IsDisposed || - !IsHandleCreated) - { - return; - } - - // 發送與控制器對等的震動(比照返回動作)。 - FeedbackService.PlaySound(SystemSounds.Exclamation); - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.ReturnStart, - _cts?.Token ?? CancellationToken.None) + VibrationPatterns.CursorMove) .SafeFireAndForget(); - - // A11y 播報在 FormClosing 中已處理。 - - DialogResult = DialogResult.Cancel; - - Close(); } catch (Exception ex) { - Debug.WriteLine($"[NumericInputDialog] HandleCancel 失敗:{ex.Message}"); + Debug.WriteLine($"[NumericInputDialog] HandleReset 失敗:{ex.Message}"); } }); - /// - /// 處理重設按鍵事件 - /// - private void HandleReset() => this.SafeInvoke(() => - { - try - { - if (GamescopeSurfaceRecovery.TryRecoverFromGamepadChord( - this, - RecreateHandle, - _gamepadController, - context: "NumericInputDialog Gamescope surface recovery 失敗")) - { - return; - } - - if (IsDisposed || - !IsHandleCreated || - _nud == null) - { - return; - } - - _nud.Value = Math.Clamp(_defaultValue, _nud.Minimum, _nud.Maximum); - _nud.Focus(); - - FeedbackService.PlaySound(SystemSounds.Asterisk); - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.CursorMove) - .SafeFireAndForget(); - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] HandleReset 失敗:{ex.Message}"); - } - }); - - /// - /// A 鍵:按鈕 → PerformClick;輸入框為空時開啟觸控鍵盤;其餘情境走確認驗證。 - /// - private void HandleGamepadA() => this.SafeInvoke(() => - { - try - { - if (ActiveControl is Button btn) - { - btn.PerformClick(); - return; - } - - if (_nud != null && (_nud.Focused || _nud.ContainsFocus)) - { - TextBox? tb = _nud.Controls.OfType().FirstOrDefault(); - if (tb != null && string.IsNullOrWhiteSpace(tb.Text)) - { - ShowTouchKeyboard(tb); - return; - } - } - - HandleConfirm(); - } - catch (Exception ex) { Debug.WriteLine($"[NumericInputDialog] HandleGamepadA 失敗: {ex.Message}"); } - }); - - /// - /// Start 鍵:在輸入框焦點時直接開啟觸控式鍵盤 - /// - private void HandleOpenTouchKeyboardFromGamepad() => this.SafeInvoke(() => - { - try - { - TextBox? tb = _nud?.Controls.OfType().FirstOrDefault(); - if (tb != null) - { - ShowTouchKeyboard(tb); - } - } - catch (Exception ex) { Debug.WriteLine($"[NumericInputDialog] HandleOpenTouchKeyboardFromGamepad 失敗: {ex.Message}"); } - }); - - /// - /// 聚焦指定輸入框並非同步開啟觸控式鍵盤。 - /// - /// 要聚焦的輸入框。 - private void ShowTouchKeyboard(TextBox tb) - { - if (tb.CanFocus && - !tb.Focused) - { - tb.Focus(); - } - - AnnounceA11y(Strings.A11y_Opening_Keyboard, interrupt: true); - - Task.Run(async () => - { - try - { - await Task.Delay(150, _cts?.Token ?? CancellationToken.None); - - if (TouchKeyboardService.IsVisible()) return; - - await this.SafeInvokeAsync(() => - { - try - { - bool opened = TouchKeyboardService.TryOpen(); - - if (opened) - { - FeedbackService.PlaySound(SystemSounds.Asterisk); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] ShowTouchKeyboard 內層失敗: {ex.Message}"); - } - }); - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] ShowTouchKeyboard 背景工作失敗: {ex.Message}"); - } - }, _cts?.Token ?? CancellationToken.None).SafeFireAndForget(); - } - - /// - /// X 鍵:刪除選取文字或游標前一字元 - /// - private void HandleBackspace() => this.SafeInvoke(() => - { - try - { - if (_nud == null) - { - return; - } - - TextBox? tb = _nud.Controls.OfType().FirstOrDefault(); - - if (tb == null || - tb.IsDisposed || - tb.ReadOnly) - { - return; - } - - // 實施手動字串處理以符合「不模擬按鍵」的安全性紅線。 - // 透過直接操作 TextBox.Text,能觸發 NumericUpDown 的內部驗證機制且不依賴 Win32 訊息注入。 - if (tb.SelectionLength > 0) - { - tb.SelectedText = string.Empty; - } - else if (tb.SelectionStart > 0) - { - int start = tb.SelectionStart; - - tb.Text = tb.Text.Remove(start - 1, 1); - tb.SelectionStart = start - 1; - } - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] HandleBackspace 失敗: {ex.Message}"); - } - }); - - /// - /// LT 鍵:向左導覽游標 - /// - private void HandleLTNav() => MoveCaret(false); - - /// - /// RT 鍵:向右導覽游標 - /// - private void HandleRTNav() => MoveCaret(true); - - /// - /// 執行視覺警示閃爍效果 - /// - /// Task - private async Task FlashAlertAsync() - { - if (IsDisposed || - !IsHandleCreated || - Interlocked.CompareExchange(ref _isFlashing, 1, 0) != 0) - { - return; - } - - // 僅在對話框生命週期仍有效時建立警示權杖,避免關閉途中留下失去連結的動畫。 - CancellationTokenSource? newAlertCts = _cts.TryCreateLinkedTokenSource(); - - if (newAlertCts == null) - { - Interlocked.Exchange(ref _isFlashing, 0); - - return; - } - - Interlocked.Exchange(ref _alertCts, newAlertCts)?.CancelAndDispose(); - - CancellationToken token = newAlertCts.Token; - - try - { - bool isDark = this.IsDarkModeActive(); - - // 決定警示色。 - Color alertColor = SystemInformation.HighContrast ? - SystemColors.Highlight : - (isDark ? Color.Firebrick : Color.DarkOrange); - - void ApplyAlertVisuals(float intensity) - { - if (IsDisposed || - !IsHandleCreated || - _nud == null) - { - return; - } - - if (SystemInformation.HighContrast) - { - bool isAlert = intensity > 0.5f; - - Color hcBack = isAlert ? - alertColor : - SystemColors.Window, - hcFore = isAlert ? - SystemColors.HighlightText : - SystemColors.WindowText; - - _nud.UpdateRecursive(hcBack, hcFore); - } - else - { - Color pureBase = isDark ? - Color.White : - Color.Black; - - int rN = (int)(pureBase.R + (alertColor.R - pureBase.R) * intensity), - gN = (int)(pureBase.G + (alertColor.G - pureBase.G) * intensity), - bN = (int)(pureBase.B + (alertColor.B - pureBase.B) * intensity); - - Color flashColor = Color.FromArgb(255, rN, gN, bN); - // WCAG 相對亮度精確切換閾值(crossover L≈0.1791),修復 YUV≈128 近似在切換帶(intensity≈0.75) - // 導致文字對比跌破 AA(3.5~4.2:1)的問題。修復後全程 ≥4.64:1 AA; - // 14f bold 大型文字全程 ≥4.5:1 AAA。 - static float FLin(int c) { float f = c / 255f; return f <= 0.04045f ? f / 12.92f : MathF.Pow((f + 0.055f) / 1.055f, 2.4f); } - Color flashFore = (0.2126f * FLin(flashColor.R) + 0.7152f * FLin(flashColor.G) + 0.0722f * FLin(flashColor.B)) > 0.1791f - ? Color.Black - : Color.White; - - _nud.UpdateRecursive(flashColor, flashFore); - } - } - - if (!SystemInformation.UIEffectsEnabled || - !AppSettings.Current.EnableAnimatedVisualAlerts) - { - await this.SafeInvokeAsync(() => ApplyAlertVisuals(1.0f)); - - await Task.Delay(800, token); - - return; - } - - using PeriodicTimer timer = new(TimeSpan.FromMilliseconds(AppSettings.TargetFrameTimeMs)); - - long startTime = Stopwatch.GetTimestamp(); - - while (await timer.WaitForNextTickAsync(token)) - { - long elapsedTicks = Stopwatch.GetTimestamp() - startTime; - - double elapsedMs = (double)elapsedTicks / Stopwatch.Frequency * 1000.0; - - if (elapsedMs >= AppSettings.PhotoSafeFrequencyMs) - { - break; - } - - double angle = elapsedMs / AppSettings.PhotoSafeFrequencyMs * 2.0 * Math.PI - (Math.PI / 2.0); - - float intensity = (float)((Math.Sin(angle) + 1.0) / 2.0); - - await this.SafeInvokeAsync(() => ApplyAlertVisuals(intensity)); - } - } - catch (OperationCanceledException) - { - // 正常取消。 - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] FlashAlertAsync 失敗:{ex.Message}"); - } - finally - { - Interlocked.Exchange(ref _isFlashing, 0); - Interlocked.Exchange(ref _alertCts, null)?.CancelAndDispose(); - - // 確保 UI 狀態還原。 - this.SafeInvoke(() => - { - try - { - if (IsDisposed || - !IsHandleCreated || - _nud == null) - { - return; - } - - // 關鍵修正:遞歸重設顏色,觸發 .NET 10 原生主題引擎還原正確配色,防止閃爍殘留。 - _nud.ResetThemeRecursive(); - - // 恢復焦點視覺狀態。 - UpdateFocusVisuals(_nud.Focused || _nud.ContainsFocus); - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] FlashAlertAsync 還原失敗:{ex.Message}"); - } - }); - } - } - - /// - /// 內部 A11y 廣播方法 - /// - /// 要廣播的訊息 - /// 是否中斷目前的廣播 - private void AnnounceA11y( - string message, - bool interrupt = false) - { - if (IsDisposed || - string.IsNullOrEmpty(message)) - { - return; - } - - long currentId = Interlocked.Increment(ref _a11yDebounceId); - - Task.Run(async () => - { - try - { - // 統一 Audio Ducking 避讓延遲。 - await Task.Delay(AppSettings.AudioDuckingDelayMs, _cts?.Token ?? CancellationToken.None); - - if (Interlocked.Read(ref _a11yDebounceId) == currentId && - !IsDisposed && - IsHandleCreated) - { - await this.SafeInvokeAsync(() => - _announcer?.Announce(message, interrupt && AppSettings.Current.A11yInterruptEnabled)); - } - } - catch (OperationCanceledException) - { - // 正常取消。 - } - catch (Exception ex) - { - Debug.WriteLine($"[A11y] 對話框本地廣播失敗:{ex.Message}"); - } - }, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - - /// - /// 取得目前對話框所屬的主視窗 - /// - /// 若 Owner 為 則回傳該實例,否則為 null。 - private MainForm? GetOwnerMainForm() => Owner as MainForm; - - /// - /// 更新控制項焦點狀態的視覺表現 - /// - /// 指示控制項是否具有焦點 - private void UpdateFocusVisuals(bool isFocused) - { - try - { - if (_nud == null || - _nud.IsDisposed) - { - return; - } - - // 綜合判斷:只要數值框本身有焦點,或是旁邊的加減按鈕有焦點,數值框都應該保持高亮。 - bool shouldHighlight = isFocused || - (_btnPlus != null && _btnPlus.Focused) || - (_btnMinus != null && _btnMinus.Focused); - - if (shouldHighlight) - { - if (SystemInformation.HighContrast) - { - _nud.UpdateRecursive(SystemColors.Highlight, SystemColors.HighlightText); - } - else - { - bool isDark = this.IsDarkModeActive(); - - // 淺色模式:黑底白字、深色模式:白底黑字。 - _nud.UpdateRecursive( - isDark ? Color.White : Color.Black, - isDark ? Color.Black : Color.White); - } - } - else - { - _nud.ResetThemeRecursive(); - } - - // 當具有焦點時,強化游標。 - if (isFocused) - { - UpdateCaretWidth(); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] UpdateFocusVisuals 失敗:{ex.Message}"); - } - } - - /// - /// 根據 DPI 與無障礙設定更新數值框內部游標寬度 - /// - private void UpdateCaretWidth() - { - try - { - if (_nud == null || - _nud.IsDisposed || - !_nud.IsHandleCreated) - { - return; - } - - // 尋找 NUD 內部的 TextBox 子控制項。 - TextBox? innerTextBox = _nud.Controls.OfType().FirstOrDefault(); - - if (innerTextBox == null || - !innerTextBox.IsHandleCreated) - { - return; - } - - // 基礎寬度 3px,隨 DPI 縮放。 - float scale = DeviceDpi / AppSettings.BaseDpi; - - int caretWidth = (int)Math.Max(3, 3 * scale); - - // 高對比模式下額外加粗。 - if (SystemInformation.HighContrast) - { - caretWidth += (int)(2 * scale); - } - - int caretHeight = innerTextBox.Height; - - // 若寬高與上次一致,則略過 Win32 API 調用,減少 UI 閃爍感。 - if (caretWidth == _lastCaretWidth && - caretHeight == _lastCaretHeight) - { - return; - } - - _lastCaretWidth = caretWidth; - _lastCaretHeight = caretHeight; - - // 使用 Win32 API 重新建立游標。 - if (User32.CreateCaret(innerTextBox.Handle, IntPtr.Zero, caretWidth, caretHeight)) - { - User32.ShowCaret(innerTextBox.Handle); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] UpdateCaretWidth 失敗:{ex.Message}"); - } - } - - /// - /// 更新視窗不透明度。 - /// - private void UpdateOpacity() - { - try - { - // 根據規範,若系統開啟高對比模式,則強制為 1.0 以確保絕對可讀性。 - if (SystemInformation.HighContrast) - { - Opacity = 1.0; - - return; - } - - // 數值輸入框鎖定 1.0 不透明度以確保輸入清晰度。 - Opacity = 1.0; - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] UpdateOpacity 失敗:{ex.Message}"); - } - } - - /// - /// 更新視窗最小尺寸與按鈕佈局約束 - /// - private void UpdateMinimumSize() - { - try - { - float currentDpi = DeviceDpi; - - if (!DialogLayoutHelper.TryBeginDpiLayout(currentDpi, ref _lastAppliedDpi)) - { - return; - } - - float scale = currentDpi / AppSettings.BaseDpi; - - // 內容感知:更新所有按鈕的佈局約束(Bold 預測)。 - // 確保按鈕在獲得焦點變為粗體時,物理邊界保持絕對靜止,達成 Zero-Jitter。 - UpdateButtonConstraints(_btnOk, scale); - UpdateButtonConstraints(_btnCancel, scale); - UpdateButtonConstraints(_btnPlus, scale); - UpdateButtonConstraints(_btnMinus, scale); - UpdateButtonConstraints(_btnReset, scale); - - // 改用內容偏好尺寸作為基準。 - _tlpGrid?.PerformLayout(); - - Size contentPref = _tlpGrid?.GetPreferredSize(Size.Empty) ?? - new((int)(450 * scale), (int)(250 * scale)); - - Rectangle workArea = Screen.GetWorkingArea(this); - - // 計算邊框與標題列所需的額外空間(比照 HelpDialog.cs)。 - int frameW = SystemInformation.FrameBorderSize.Width * 2, - frameH = SystemInformation.FrameBorderSize.Height * 2, - captionH = SystemInformation.CaptionHeight; - - (int maxFitW, int maxFitH) = DialogLayoutHelper.GetMaxFitSize(workArea); - - // 視窗寬度:內容寬度 + 表單 Padding + 框架,以工作區上限為準。 - int formW = Math.Clamp( - contentPref.Width + Padding.Horizontal + frameW + 8, - (int)(450 * scale), - maxFitW); - - // 視窗高度:依實際內容偏好尺寸計算,上限為工作區可用高度(保留 40px 邊界)。 - int desiredMinHeight = (int)(300 * scale), - naturalH = contentPref.Height + Padding.Vertical + captionH + frameH + 8, - formH = Math.Clamp(naturalH, desiredMinHeight, Math.Max(desiredMinHeight, maxFitH)); - - MinimumSize = new Size(Math.Min((int)(450 * scale), maxFitW), desiredMinHeight); - - Size = new Size(formW, formH); - - // 佈局擴張後,執行智慧定位檢查。 - ApplySmartPosition(); - - // 高對比模式下強制 100% 不透明度。 - if (SystemInformation.HighContrast) - { - UpdateOpacity(); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] UpdateMinimumSize 失敗:{ex.Message}"); - } - } - - /// - /// 更新單個按鈕的佈局約束與最小尺寸鎖定 - /// - /// 目標按鈕 - /// 目前 DPI 縮放比例 - private void UpdateButtonConstraints(Button? btn, float scale) - { - try - { - if (btn == null || - btn.IsDisposed) - { - return; - } - - // 取得專屬於此視窗 DPI 的 Bold 字體實例。 - Font boldFont = MainForm.GetSharedA11yFont(DeviceDpi, FontStyle.Bold, btn.Font.FontFamily); - - // 眼動儀友善:抗抖動寬度鎖定(Anti-Jitter Lock)。 - DialogLayoutHelper.UpdateButtonMinimumSize(btn, boldFont, scale, 120, 60, 32, 24); - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] UpdateButtonConstraints 失敗:{ex.Message}"); - } - } - - /// - /// 執行智慧定位修正,確保視窗不會跑出螢幕邊界 - /// - private void ApplySmartPosition() - { - // 脫離目前的佈局計算循環,確保所有 StartPosition 與 AutoSize 已處理完畢。 - this.SafeBeginInvoke(() => - { - try - { - if (IsDisposed || - !IsHandleCreated) - { - return; - } - - // 強制同步最新的實體佈局尺寸(關鍵:確保 Width/Height 是縮放後的真實值)。 - PerformLayout(); - - if (InputBoxLayoutManager.TryGetClampedLocation(this, out Point clampedLocation)) - { - Location = clampedLocation; - - // 告知使用者視窗已修正位置。 - AnnounceA11y(Strings.A11y_SnapBack); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[NumericInputDialog] ApplySmartPosition 失敗:{ex.Message}"); - } - }); - } - /// /// 初始化對話框組件 /// @@ -2420,4 +1031,4 @@ void StopRepeat() btn.Disposed += (s, e) => StopRepeat(); } -} \ No newline at end of file +} diff --git a/src/InputBox/Core/Controls/PhraseEditDialog.A11y.cs b/src/InputBox/Core/Controls/PhraseEditDialog.A11y.cs new file mode 100644 index 0000000..332c73e --- /dev/null +++ b/src/InputBox/Core/Controls/PhraseEditDialog.A11y.cs @@ -0,0 +1,305 @@ +using InputBox.Core.Configuration; +using InputBox.Core.Extensions; +using InputBox.Core.Feedback; +using InputBox.Core.Services; +using System.Diagnostics; +using System.Media; + +namespace InputBox.Core.Controls; + +// 阻擋設計工具。 +partial class DesignerBlocker { }; + +/// +/// 片語編輯對話框(無障礙與驗證回饋分部)。 +/// 本分部檔案包含 A11y 廣播、驗證失敗閃爍,以及字數上限回饋等成員。 +/// +internal sealed partial class PhraseEditDialog +{ + /// + /// 針對驗證失敗的輸入框提供焦點、音效、震動與視覺提示 + /// + /// 驗證失敗的輸入框。 + /// 要播報的錯誤訊息。 + private void NotifyValidationFailure(TextBox target, string message) + { + if (target.CanFocus && !target.Focused) + { + target.Focus(); + } + + AnnounceA11y(message, interrupt: true); + + FeedbackService.PlaySound(SystemSounds.Hand); + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.ActionFail, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + + FlashValidationCueAsync(target).SafeFireAndForget(); + } + + /// + /// 暫時閃爍輸入框以提供驗證失敗的視覺提示 + /// + /// 要閃爍提示的輸入框。 + /// 非同步作業。 + private async Task FlashValidationCueAsync(TextBox target) + { + if (target.IsDisposed || + !IsHandleCreated || + Interlocked.CompareExchange(ref _isFlashing, 1, 0) != 0) + { + return; + } + + // 僅在對話框生命週期仍有效時建立警示權杖,避免關閉途中留下失去連結的動畫。 + CancellationTokenSource? newAlertCts = _cts.TryCreateLinkedTokenSource(); + + if (newAlertCts == null) + { + Interlocked.Exchange(ref _isFlashing, 0); + + return; + } + + Interlocked.Exchange(ref _alertCts, newAlertCts)?.CancelAndDispose(); + + CancellationToken token = newAlertCts.Token; + + try + { + bool isDark = target.IsDarkModeActive(); + Color alertColor = FlashAlertAnimator.GetAlertColor(isDark, SystemInformation.HighContrast); + + void ApplyAlertVisuals(float intensity) + { + if (target.IsDisposed || + !IsHandleCreated) + { + return; + } + + (Color back, Color fore) = FlashAlertAnimator.ComputeFrameColors( + intensity, + isDark, + alertColor, + SystemInformation.HighContrast); + + target.BackColor = back; + target.ForeColor = fore; + } + + await FlashAlertAnimator.RunAsync(this, ApplyAlertVisuals, token); + } + catch (OperationCanceledException) + { + // 正常取消。 + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] FlashValidationCueAsync 失敗:{ex.Message}"); + } + finally + { + Interlocked.Exchange(ref _isFlashing, 0); + Interlocked.Exchange(ref _alertCts, null)?.CancelAndDispose(); + + // 確保 UI 狀態還原。 + this.SafeInvoke(() => + { + try + { + if (target.IsDisposed || + !IsHandleCreated) + { + return; + } + + if (target.Focused) + { + ApplyInputBoxStrongVisual(target); + } + else + { + ResetInputBoxVisual(target); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] 驗證提示動畫 UI 還原失敗:{ex.Message}"); + } + }); + } + } + + /// + /// 依剩餘字元數分級,僅在接近上限時回傳有效 bucket。 + /// + /// 距離上限的剩餘字元數。 + /// 警示等級(0 ~ 3);超過警示閾值時回傳 -1。 + private static int GetTextLimitWarningBucket(int remainingCharacters) + { + return remainingCharacters switch + { + <= 0 => 3, + <= 2 => 2, + <= 5 => 1, + <= TextLimitWarningThreshold => 0, + _ => -1 + }; + } + + /// + /// 當片語欄位長度變動時,提供接近字數上限的物理預警與硬牆回饋。 + /// + /// 監控中的輸入框。 + /// 欄位的最大字元數限制。 + /// 上次觀察到的長度(ref,會被更新)。 + /// 上次警示的 bucket 等級(ref,會被更新)。 + private void HandleTextLimitFeedbackFromLengthChange( + TextBox textBox, + int maxLength, + ref int lastObservedLength, + ref int lastWarningBucket) + { + if (textBox.IsDisposed) + { + return; + } + + int currentLength = textBox.TextLength; + + if (currentLength < lastObservedLength) + { + lastObservedLength = currentLength; + lastWarningBucket = currentLength >= maxLength - TextLimitWarningThreshold ? + GetTextLimitWarningBucket(maxLength - currentLength) : + -1; + return; + } + + int remainingCharacters = maxLength - currentLength; + + if (remainingCharacters > TextLimitWarningThreshold) + { + lastObservedLength = currentLength; + lastWarningBucket = -1; + return; + } + + int currentBucket = GetTextLimitWarningBucket(remainingCharacters); + + if (currentLength > lastObservedLength && + (currentBucket != lastWarningBucket || remainingCharacters <= 2)) + { + if (remainingCharacters <= 0) + { + FeedbackService.PlaySound(SystemSounds.Beep); + } + + FeedbackService.VibrateSequenceAsync( + _gamepadController, + VibrationPatterns.GetTextLimitSequence( + remainingCharacters, + _gamepadController?.VibrationMotorSupport ?? VibrationMotorSupport.None), + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + + lastObservedLength = currentLength; + lastWarningBucket = currentBucket; + } + + /// + /// 使用者在字數已滿時仍嘗試輸入一般字元,播放硬牆回饋。 + /// + /// 目標輸入框。 + /// 欄位的最大字元數限制。 + /// 上次觸發硬牆回饋的 UTC 時間戳(ref,用於節流)。 + /// 按鍵事件引數。 + private void HandleTextLimitKeyPress(TextBox textBox, int maxLength, ref DateTime lastWallUtc, KeyPressEventArgs e) + { + if (textBox.IsDisposed || + char.IsControl(e.KeyChar) || + textBox.TextLength < maxLength) + { + return; + } + + if ((DateTime.UtcNow - lastWallUtc).TotalMilliseconds < RepeatedBoundaryFeedbackThrottleMs) + { + return; + } + + lastWallUtc = DateTime.UtcNow; + FeedbackService.PlaySound(SystemSounds.Beep); + FeedbackService.VibrateSequenceAsync( + _gamepadController, + VibrationPatterns.GetTextLimitSequence( + 0, + _gamepadController?.VibrationMotorSupport ?? VibrationMotorSupport.None), + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + + /// + /// 廣播無障礙訊息 + /// + /// 要廣播的訊息 + /// 是否中斷目前的廣播 + private void AnnounceA11y(string message, bool interrupt = false) + { + if (IsDisposed || + string.IsNullOrEmpty(message)) + { + return; + } + + if (Owner is MainForm mainForm) + { + mainForm.AnnounceA11y(message, interrupt); + } + else if (Owner is PhraseManagerDialog phraseManager) + { + // 嘗試寫往主視窗。 + if (phraseManager.Owner is MainForm main) + { + main.AnnounceA11y(message, interrupt); + + return; + } + } + + // 本地備援 + long currentId = Interlocked.Increment(ref _a11yDebounceId); + + Task.Run(async () => + { + try + { + await Task.Delay(AppSettings.AudioDuckingDelayMs, _cts?.Token ?? CancellationToken.None); + + if (Interlocked.Read(ref _a11yDebounceId) == currentId && + !IsDisposed && + IsHandleCreated) + { + await this.SafeInvokeAsync(() => + _announcer.Announce(message, interrupt && AppSettings.Current.A11yInterruptEnabled)); + } + } + catch (OperationCanceledException) + { + + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] A11y 廣播失敗:{ex.Message}"); + } + }, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } +} diff --git a/src/InputBox/Core/Controls/PhraseEditDialog.Gamepad.cs b/src/InputBox/Core/Controls/PhraseEditDialog.Gamepad.cs new file mode 100644 index 0000000..1fab53c --- /dev/null +++ b/src/InputBox/Core/Controls/PhraseEditDialog.Gamepad.cs @@ -0,0 +1,715 @@ +using InputBox.Core.Extensions; +using InputBox.Core.Feedback; +using InputBox.Core.Input; +using InputBox.Core.Interop; +using InputBox.Core.Services; +using InputBox.Core.Utilities; +using InputBox.Resources; +using System.ComponentModel; +using System.Diagnostics; +using System.Media; + +namespace InputBox.Core.Controls; + +// 阻擋設計工具。 +partial class DesignerBlocker { }; + +/// +/// 片語編輯對話框(遊戲控制器與輸入分部)。 +/// 本分部檔案包含遊戲控制器事件、欄位切換、游標移動、延伸選取、右鍵選單,以及觸控式鍵盤等輸入處理成員。 +/// +internal sealed partial class PhraseEditDialog +{ + /// + /// 遊戲控制器 + /// + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public IGamepadController? GamepadController + { + get => _gamepadController; + set + { + if (ReferenceEquals(_gamepadController, value)) + { + return; + } + + UnsubscribeGamepadEvents(); + + _gamepadController = value; + + if (_gamepadController != null) + { + GamepadFaceButtonProfile profile = GamepadFaceButtonProfile.GetActiveProfile(); + + _gamepadController.APressed += profile.ConfirmOnSouth ? HandleGamepadA : HandleBackOrClear; + _gamepadController.StartPressed += HandleOpenTouchKeyboardFromGamepad; + _gamepadController.BPressed += profile.ConfirmOnSouth ? HandleBackOrClear : HandleGamepadA; + _gamepadController.BackPressed += HandleCancel; + _gamepadController.LeftPressed += HandleLeft; + _gamepadController.LeftRepeat += HandleLeft; + _gamepadController.RightPressed += HandleRight; + _gamepadController.RightRepeat += HandleRight; + _gamepadController.UpPressed += HandleFieldPrev; + _gamepadController.DownPressed += HandleFieldNext; + _gamepadController.XPressed += HandleBackspace; + _gamepadController.YPressed += HandleOpenContextMenu; + _gamepadController.RSLeftPressed += HandleRSLeft; + _gamepadController.RSLeftRepeat += HandleRSLeft; + _gamepadController.RSRightPressed += HandleRSRight; + _gamepadController.RSRightRepeat += HandleRSRight; + _gamepadController.ConnectionChanged += HandleConnectionChanged; + } + } + } + + /// + /// A 鍵:按鈕 → PerformClick;輸入框為空時開啟觸控鍵盤;其餘情境走確認驗證 + /// + private void HandleGamepadA() => this.SafeInvoke(() => + { + try + { + if (ActiveControl is Button btn) + { + btn.PerformClick(); + return; + } + + TextBox? tb = GetActiveTextBox() ?? _txtName; + + if (tb != null && string.IsNullOrWhiteSpace(tb.Text)) + { + ShowTouchKeyboard(tb); + + return; + } + + HandleConfirm(); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] 控制器 A 鍵失敗:{ex.Message}"); + } + }); + + /// + /// Start 鍵:在輸入框焦點時直接開啟觸控式鍵盤(可在已有內容時修改) + /// + private void HandleOpenTouchKeyboardFromGamepad() => this.SafeInvoke(() => + { + try + { + TextBox? tb = GetActiveTextBox() ?? _txtName; + + ShowTouchKeyboard(tb); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] 控制器開啟觸控鍵盤失敗:{ex.Message}"); + } + }); + + /// + /// B 鍵:若焦點在輸入框且有內容則優先清空;否則執行取消 + /// + private void HandleBackOrClear() => this.SafeInvoke(() => + { + try + { + TextBox? tb = GetActiveTextBox(); + + if (tb != null) + { + if (tb.SelectionLength > 0) + { + tb.SelectedText = string.Empty; + + _rsSelectionAnchor = null; + + AnnounceA11y(Strings.Msg_InputCleared, interrupt: true); + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.ClearInput, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + + return; + } + + if (!string.IsNullOrEmpty(tb.Text)) + { + tb.Clear(); + + _rsSelectionAnchor = null; + + AnnounceA11y(Strings.Msg_InputCleared, interrupt: true); + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.ClearInput, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + + return; + } + } + + HandleCancel(); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] HandleBackOrClear 失敗:{ex.Message}"); + } + }); + + /// + /// 聚焦指定輸入框並非同步開啟觸控式鍵盤。 + /// + /// 要聚焦的輸入框。 + private void ShowTouchKeyboard(TextBox tb) + { + if (tb.CanFocus && !tb.Focused) + { + tb.Focus(); + } + + AnnounceA11y(Strings.A11y_Opening_Keyboard, interrupt: true); + + Task.Run(async () => + { + try + { + await Task.Delay(150, _cts?.Token ?? CancellationToken.None); + + if (TouchKeyboardService.IsVisible()) return; + + await this.SafeInvokeAsync(() => + { + try + { + bool opened = TouchKeyboardService.TryOpen(); + + if (opened) + { + FeedbackService.PlaySound(SystemSounds.Asterisk); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] 觸控鍵盤開啟失敗:{ex.Message}"); + } + }); + } + catch (OperationCanceledException) + { + + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] 開啟觸控鍵盤失敗:{ex.Message}"); + } + }, + _cts?.Token ?? CancellationToken.None).SafeFireAndForget(); + } + + /// + /// 取得目前焦點所在的 TextBox(名稱或內容)。 + /// + /// 具有焦點的輸入框;兩者皆無焦點時回傳 null。 + private TextBox? GetActiveTextBox() + { + if (_txtName.Focused) + { + return _txtName; + } + + if (_txtContent.Focused) + { + return _txtContent; + } + + return null; + } + + /// + /// 游標左移(比照 MainForm.Gamepad.cs 的 MoveCursorLeft) + /// + private void HandleLeft() => this.SafeInvoke(() => + { + try + { + TextBox? tb = GetActiveTextBox(); + + if (tb == null) + { + // 焦點在按鈕區:D-Pad 左向在確認/取消按鈕間循環。 + if (_btnOk.Focused) + { + _btnCancel.Focus(); + AnnounceA11y(_btnCancel.AccessibleName ?? _btnCancel.Text, interrupt: true); + FeedbackService.VibrateAsync(_gamepadController, VibrationPatterns.CursorMove, _cts?.Token ?? CancellationToken.None).SafeFireAndForget(); + } + else if (_btnCancel.Focused) + { + _btnOk.Focus(); + AnnounceA11y(_btnOk.AccessibleName ?? _btnOk.Text, interrupt: true); + FeedbackService.VibrateAsync(_gamepadController, VibrationPatterns.CursorMove, _cts?.Token ?? CancellationToken.None).SafeFireAndForget(); + } + + return; + } + + bool hasSelection = tb.SelectionLength > 0; + + if (hasSelection || + tb.SelectionStart > 0) + { + if (hasSelection) + { + tb.SelectionLength = 0; + } + else if (_gamepadController?.IsLeftShoulderHeld == true) + { + tb.WordJump(false); + } + else + { + tb.SelectionStart--; + } + + tb.ScrollToCaret(); + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.CursorMove, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + else + { + FeedbackService.PlaySound(SystemSounds.Beep); + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.CursorMove, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] 左移失敗:{ex.Message}"); + } + }); + + /// + /// 游標右移(比照 MainForm.Gamepad.cs 的 MoveCursorRight) + /// + private void HandleRight() => this.SafeInvoke(() => + { + try + { + TextBox? tb = GetActiveTextBox(); + + if (tb == null) + { + // 焦點在按鈕區:D-Pad 右向在取消/確認按鈕間循環。 + if (_btnCancel.Focused) + { + _btnOk.Focus(); + AnnounceA11y(_btnOk.AccessibleName ?? _btnOk.Text, interrupt: true); + FeedbackService.VibrateAsync(_gamepadController, VibrationPatterns.CursorMove, _cts?.Token ?? CancellationToken.None).SafeFireAndForget(); + } + else if (_btnOk.Focused) + { + _btnCancel.Focus(); + AnnounceA11y(_btnCancel.AccessibleName ?? _btnCancel.Text, interrupt: true); + FeedbackService.VibrateAsync(_gamepadController, VibrationPatterns.CursorMove, _cts?.Token ?? CancellationToken.None).SafeFireAndForget(); + } + + return; + } + + bool hasSelection = tb.SelectionLength > 0; + + if (hasSelection || tb.SelectionStart < tb.Text.Length) + { + if (hasSelection) + { + tb.SelectionStart += tb.SelectionLength; + tb.SelectionLength = 0; + } + else if (_gamepadController?.IsLeftShoulderHeld == true) + { + tb.WordJump(true); + } + else + { + tb.SelectionStart++; + } + + tb.ScrollToCaret(); + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.CursorMove, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + else + { + FeedbackService.PlaySound(SystemSounds.Beep); + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.CursorMove, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] 右移失敗:{ex.Message}"); + } + }); + + /// + /// 上方向鍵:在名稱欄位與內容欄位和按鈕之間切換焦點 + /// + private void HandleFieldPrev() => this.SafeInvoke(() => + { + try + { + if (_txtContent.Focused) + { + _txtName.Focus(); + } + else if (_btnOk.Focused) + { + _txtContent.Focus(); + } + else if (_btnCancel.Focused) + { + _btnOk.Focus(); + } + else if (_txtName.Focused) + { + _btnCancel.Focus(); + } + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.CursorMove, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] 欄位向前失敗:{ex.Message}"); + } + }); + + /// + /// 下方向鍵:在名稱欄位、內容欄位與按鈕之間循環焦點 + /// + private void HandleFieldNext() => this.SafeInvoke(() => + { + try + { + if (_txtName.Focused) + { + _txtContent.Focus(); + } + else if (_txtContent.Focused) + { + _btnOk.Focus(); + } + else if (_btnOk.Focused) + { + _btnCancel.Focus(); + } + else if (_btnCancel.Focused) + { + _txtName.Focus(); + } + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.CursorMove, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] 欄位向後失敗:{ex.Message}"); + } + }); + + /// + /// X 鍵:刪除選取文字或游標前一字元(比照 MainForm.Gamepad.cs 的 X 鍵刪除邏輯) + /// + private void HandleBackspace() => this.SafeInvoke(() => + { + try + { + TextBox? tb = GetActiveTextBox(); + + if (tb == null || + tb.ReadOnly) + { + return; + } + + if (tb.SelectionLength > 0) + { + tb.SelectedText = string.Empty; + } + else if (tb.SelectionStart > 0) + { + int pos = tb.SelectionStart; + + tb.Select(pos - 1, 1); + tb.SelectedText = string.Empty; + } + else + { + FeedbackService.PlaySound(SystemSounds.Beep); + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.CursorMove, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] 退格失敗:{ex.Message}"); + } + }); + + /// + /// 右搖桿左推:擴張選取範圍向左(比照 MainForm.Gamepad.cs 的 ExpandSelection) + /// + private void HandleRSLeft() => this.SafeInvoke(() => + { + try + { + ExpandSelection(-1); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] 右摘桿左移失敗:{ex.Message}"); + } + }); + + /// + /// 右搖桿右推:擴張選取範圍向右 + /// + private void HandleRSRight() => this.SafeInvoke(() => + { + try + { + ExpandSelection(1); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] 右摘桿右移失敗:{ex.Message}"); + } + }); + + /// + /// Y 鍵:開啟焦點 TextBox 的原生右鍵選單 + /// + 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) + { + return; + } + + // 在游標位置附近顯示內建右鍵選單。 + Point caretPos = tb.GetPositionFromCharIndex(tb.SelectionStart); + + tb.ContextMenuStrip?.Show(tb, caretPos); + + // TextBox 沒有 ContextMenuStrip 時,透過模擬 Shift+F10 觸發原生選單。 + if (tb.ContextMenuStrip == null) + { + User32.SendMessage(tb.Handle, 0x007B, tb.Handle, unchecked((nint)0xFFFFFFFF)); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] 開啟右鍵選單失敗:{ex.Message}"); + } + }); + + /// + /// 在重建 PhraseEditDialog surface 前關閉目前 TextBox 的右鍵選單。 + /// + private void CloseActiveTextBoxContextMenu() + { + GetActiveTextBox()?.ContextMenuStrip?.Close(); + } + + /// + /// 擴張或縮減文字選取範圍(比照 MainForm.Gamepad.cs 的 ExpandSelection) + /// + /// 方向,正數表示向右擴張,負數表示向左擴張。 + private void ExpandSelection(int direction) + { + TextBox? tb = GetActiveTextBox(); + + if (tb == null) + { + return; + } + + (int anchor, int caret) = tb.ResolveSelectionAnchor(_rsSelectionAnchor); + + _rsSelectionAnchor = anchor; + + int safeDirection = Math.Sign(direction); + bool wordGranularity = _gamepadController?.IsLeftShoulderHeld == true || + _gamepadController?.IsRightShoulderHeld == true; + + int newCaret = wordGranularity ? + tb.GetWordJumpTarget(caret, safeDirection > 0) : + Math.Clamp(caret + safeDirection, 0, tb.TextLength); + + if (newCaret == caret) + { + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.ActionFail, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + + return; + } + + // 使用 Win32 EM_SETSEL 正確設定選取範圍(含反向選取)。 + tb.SetSelectionWithActiveEdge(anchor, newCaret); + + PlaySelectionFeedback(safeDirection, wordGranularity); + } + + /// + /// 推進某類觸覺回饋的 burst 等級,用於快速連發時做輕量阻尼。 + /// + /// 上次觸發的 UTC 時間戳(ref,會被更新)。 + /// 目前 burst 等級(ref,會被累加或重設)。 + /// 視為快速連發的時間視窗(毫秒)。 + /// 更新後的 burst 等級(0 ~ 3)。 + private static int AdvanceFeedbackBurst(ref DateTime lastUtc, ref int burstLevel, int fastWindowMs) + { + DateTime now = DateTime.UtcNow; + burstLevel = (now - lastUtc).TotalMilliseconds <= fastWindowMs ? + Math.Min(burstLevel + 1, 3) : + 0; + lastUtc = now; + + return burstLevel; + } + + /// + /// 根據選取粒度與速度播放不同的右搖桿文字選取回饋。 + /// + /// 選取方向;負值為向左,正值為向右。 + /// 是否為單字粒度選取。 + private void PlaySelectionFeedback(int direction, bool wordGranularity) + { + IGamepadController? controller = _gamepadController; + + if (controller == null || + !controller.IsConnected) + { + return; + } + + int burstLevel = wordGranularity ? + 0 : + AdvanceFeedbackBurst(ref _lastSelectionFeedbackUtc, ref _selectionFeedbackBurstLevel, SelectionBurstWindowMs); + + FeedbackService.VibrateSequenceAsync( + controller, + VibrationPatterns.GetSelectionSequence(direction, wordGranularity, burstLevel, controller.VibrationMotorSupport), + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + + FeedbackService.PlaySelectionCue(wordGranularity, burstLevel); + } + + /// + /// 控制器重新連線後恢復輪詢狀態。 + /// + /// 新的控制器連線狀態。 + private void HandleConnectionChanged(bool connected) + { + try + { + if (connected) + { + _gamepadController?.Resume(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] 控制器連線變更:{ex.Message}"); + } + } + + /// + /// 解除目前片語編輯對話框所綁定的控制器事件 + /// + private void UnsubscribeGamepadEvents() + { + try + { + if (_gamepadController != null) + { + _gamepadController.APressed -= HandleGamepadA; + _gamepadController.APressed -= HandleBackOrClear; + _gamepadController.StartPressed -= HandleOpenTouchKeyboardFromGamepad; + _gamepadController.BPressed -= HandleGamepadA; + _gamepadController.BPressed -= HandleBackOrClear; + _gamepadController.BackPressed -= HandleCancel; + _gamepadController.LeftPressed -= HandleLeft; + _gamepadController.LeftRepeat -= HandleLeft; + _gamepadController.RightPressed -= HandleRight; + _gamepadController.RightRepeat -= HandleRight; + _gamepadController.UpPressed -= HandleFieldPrev; + _gamepadController.DownPressed -= HandleFieldNext; + _gamepadController.XPressed -= HandleBackspace; + _gamepadController.YPressed -= HandleOpenContextMenu; + _gamepadController.RSLeftPressed -= HandleRSLeft; + _gamepadController.RSLeftRepeat -= HandleRSLeft; + _gamepadController.RSRightPressed -= HandleRSRight; + _gamepadController.RSRightRepeat -= HandleRSRight; + _gamepadController.ConnectionChanged -= HandleConnectionChanged; + } + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] UnsubscribeGamepadEvents 失敗:{ex.Message}"); + } + } +} diff --git a/src/InputBox/Core/Controls/PhraseEditDialog.Layout.cs b/src/InputBox/Core/Controls/PhraseEditDialog.Layout.cs new file mode 100644 index 0000000..09fe8ef --- /dev/null +++ b/src/InputBox/Core/Controls/PhraseEditDialog.Layout.cs @@ -0,0 +1,435 @@ +using InputBox.Core.Configuration; +using InputBox.Core.Extensions; +using InputBox.Core.Utilities; +using InputBox.Resources; +using Microsoft.Win32; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace InputBox.Core.Controls; + +// 阻擋設計工具。 +partial class DesignerBlocker { }; + +/// +/// 片語編輯對話框(版面配置與視覺分部)。 +/// 本分部檔案包含 DPI 與系統偏好變更處理、字數標籤、最小尺寸、智慧定位,以及輸入框焦點視覺等成員。 +/// +internal sealed partial class PhraseEditDialog +{ + /// + /// 建立控制項 Handle 後套用最小尺寸、系統事件訂閱與初始定位。 + /// + /// 控制項事件參數。 + protected override void OnHandleCreated(EventArgs e) + { + base.OnHandleCreated(e); + + UpdateMinimumSize(); + + SystemEvents.UserPreferenceChanged -= SystemEvents_UserPreferenceChanged; + SystemEvents.UserPreferenceChanged += SystemEvents_UserPreferenceChanged; + + this.SafeBeginInvoke(ApplySmartPosition); + } + + /// + /// DPI 變更時重新量測按鈕尺寸與對話框最小尺寸。 + /// + /// DPI 變更事件參數。 + protected override void OnDpiChanged(DpiChangedEventArgs e) + { + try + { + base.OnDpiChanged(e); + + this.SafeInvoke(() => + { + try + { + // DPI 變更後刷新字型快取引用(共享快取依 DPI 分開儲存,必須重新取得)。 + _a11yFont = MainForm.GetSharedA11yFont(DeviceDpi, FontStyle.Regular); + _boldFont = MainForm.GetSharedA11yFont(DeviceDpi, FontStyle.Bold); + Font = _a11yFont; + + // 輸入框使用 2.0× 倍率字型(已明確設定,不繼承 Form.Font,需手動更新)。 + Font sharedInputFont = MainForm.GetSharedA11yFont( + DeviceDpi, + FontStyle.Regular, + _a11yFont?.FontFamily, + 2.0f); + _txtName.Font = sharedInputFont; + _txtContent.Font = sharedInputFont; + + // 重新掛載眼動儀回饋,刷新 ButtonVisualState 中儲存的字型引用。 + _btnOk.AttachEyeTrackerFeedback( + baseDescription: Strings.Phrase_A11y_Btn_Confirm_Desc, + regularFont: _a11yFont, + boldFont: _boldFont, + formCt: _cts?.Token ?? CancellationToken.None); + + _btnCancel.AttachEyeTrackerFeedback( + baseDescription: Strings.Phrase_A11y_Btn_Cancel_Desc, + regularFont: _a11yFont, + boldFont: _boldFont, + formCt: _cts?.Token ?? CancellationToken.None); + + UpdateButtonMinimumSizes(); + UpdateMinimumSize(); + ApplySmartPosition(); + _btnOk.Invalidate(); + _btnCancel.Invalidate(); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] OnDpiChanged 延遲邏輯失敗:{ex.Message}"); + } + }); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] OnDpiChanged 失敗:{ex.Message}"); + } + } + + /// + /// 使用者結束調整視窗大小後重新套用智慧定位。 + /// + /// 事件參數。 + protected override void OnResizeEnd(EventArgs e) + { + base.OnResizeEnd(e); + + ApplySmartPosition(); + } + + /// + /// Handle 銷毀時確保解除靜態系統事件訂閱 + /// + /// 控制項事件參數。 + protected override void OnHandleDestroyed(EventArgs e) + { + try + { + SystemEvents.UserPreferenceChanged -= SystemEvents_UserPreferenceChanged; + } + finally + { + base.OnHandleDestroyed(e); + } + } + + /// + /// 系統偏好設定變更時同步更新按鈕尺寸、最小尺寸與焦點視覺 + /// + /// 事件來源。 + /// 系統偏好設定事件參數。 + private void SystemEvents_UserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e) + { + try + { + if (e.Category is UserPreferenceCategory.Accessibility or + UserPreferenceCategory.Color or + UserPreferenceCategory.General) + { + this.SafeInvoke(() => + { + UpdateButtonMinimumSizes(); + UpdateMinimumSize(forceRecalculate: true); + ApplySmartPosition(); + + _btnOk.Invalidate(); + _btnCancel.Invalidate(); + + UpdateNameCharCount(); + UpdateContentCharCount(); + + TextBox? active = GetActiveTextBox(); + + if (active != null) + { + ApplyInputBoxStrongVisual(active); + } + }); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] SystemEvents_UserPreferenceChanged 失敗:{ex.Message}"); + } + } + + /// + /// 輸入框取得焦點時套用強化焦點視覺。 + /// + /// 觸發事件的輸入框。 + /// 事件參數。 + private void HandleTextBoxEnter(object? sender, EventArgs eventArgs) + { + try + { + if (sender is TextBox textBox) + { + ApplyInputBoxStrongVisual(textBox); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] HandleTextBoxEnter 失敗:{ex.Message}"); + } + } + + /// + /// 輸入框失去焦點時還原一般視覺樣式。 + /// + /// 觸發事件的輸入框。 + /// 事件參數。 + private void HandleTextBoxLeave(object? sender, EventArgs eventArgs) + { + try + { + if (sender is TextBox textBox) + { + ResetInputBoxVisual(textBox); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] HandleTextBoxLeave 失敗:{ex.Message}"); + } + } + + /// + /// 套用與主輸入框一致的強視覺焦點樣式(高對比優先,其次主題感知反轉)。 + /// + /// 要套用焦點樣式的輸入框。 + private static void ApplyInputBoxStrongVisual(TextBox textBox) + { + if (textBox.IsDisposed) + { + return; + } + + if (SystemInformation.HighContrast) + { + textBox.BackColor = SystemColors.Highlight; + textBox.ForeColor = SystemColors.HighlightText; + + return; + } + + if (textBox.IsDarkModeActive()) + { + // 深色模式:反轉為白底黑字。 + textBox.BackColor = Color.White; + textBox.ForeColor = Color.Black; + } + else + { + // 淺色模式:反轉為黑底白字。 + textBox.BackColor = Color.Black; + textBox.ForeColor = Color.White; + } + } + + /// + /// 還原輸入框為系統預設背景與前景色 + /// + /// 目標輸入框。 + private static void ResetInputBoxVisual(TextBox tb) + { + if (tb.IsDisposed) + { + return; + } + + tb.BackColor = Color.Empty; + tb.ForeColor = Color.Empty; + } + + /// + /// 更新片語名稱字元數提示標籤({current}/{max}),近上限時顯示橙色。 + /// + private void UpdateNameCharCount() + { + if (_lblNameCount == null || _lblNameCount.IsDisposed) + { + return; + } + + int len = _txtName.TextLength; + int max = AppSettings.MaxPhraseNameLength; + string countLabel = GetPhraseTextOrFallback("Phrase_Edit_Name_Count", "Name length: "); + string countText = $"{countLabel}{len}/{max}"; + + _lblNameCount.Text = countText; + _lblNameCount.AccessibleName = countText; + _lblNameCount.AccessibleDescription = $"{Strings.Phrase_A11y_Edit_Name_Desc} {countText}"; + + if (SystemInformation.HighContrast) + { + _lblNameCount.ForeColor = Color.Empty; + + return; + } + + _lblNameCount.ForeColor = len >= max - 10 ? + Color.DarkOrange : + Color.Empty; + } + + /// + /// 更新片語內容字元數提示標籤({current}/{max}),近上限時顯示橙色。 + /// + private void UpdateContentCharCount() + { + if (_lblContentCount == null || _lblContentCount.IsDisposed) + { + return; + } + + int len = _txtContent.TextLength; + int max = AppSettings.MaxInputLength; + string countLabel = GetPhraseTextOrFallback("Phrase_Edit_Content_Count", "Content length: "); + string countText = $"{countLabel}{len}/{max}"; + + _lblContentCount.Text = countText; + _lblContentCount.AccessibleName = countText; + _lblContentCount.AccessibleDescription = $"{Strings.Phrase_A11y_Edit_Content_Desc} {countText}"; + + if (SystemInformation.HighContrast) + { + _lblContentCount.ForeColor = Color.Empty; + + return; + } + + _lblContentCount.ForeColor = len >= max - 50 ? + Color.DarkOrange : + Color.Empty; + } + + /// + /// 更新按鈕最小尺寸(抗抖動 + WCAG 2.5.5 AAA 44×44) + /// + private void UpdateButtonMinimumSizes() + { + float scale = DeviceDpi / AppSettings.BaseDpi; + + UpdateSingleButtonMinimumSize(_btnOk, scale); + UpdateSingleButtonMinimumSize(_btnCancel, scale); + } + + /// + /// 預先鎖定動態字數標籤的最小寬度,避免數值變化時造成版面抖動。 + /// + private void UpdateCountLabelMinimumWidths() + { + UpdateSingleCountLabelMinimumWidth( + _lblNameCount, + GetPhraseTextOrFallback("Phrase_Edit_Name_Count", "Name length: "), + AppSettings.MaxPhraseNameLength); + UpdateSingleCountLabelMinimumWidth( + _lblContentCount, + GetPhraseTextOrFallback("Phrase_Edit_Content_Count", "Content length: "), + AppSettings.MaxInputLength); + } + + /// + /// 鎖定單一動態標籤的寬度,讓最長狀態文字也不會改變物理尺寸。 + /// + /// 要鎖定寬度的標籤;為 null 時略過。 + /// 標籤前綴文字,用於計算最寬狀態。 + /// 欄位的最大值,用於計算最寬文字。 + private static void UpdateSingleCountLabelMinimumWidth(Label? label, string labelPrefix, int maxValue) + { + if (label == null || + label.IsDisposed) + { + return; + } + + string widestText = $"{labelPrefix}{maxValue}/{maxValue}"; + Size measured = TextRenderer.MeasureText( + widestText, + label.Font, + Size.Empty, + TextFormatFlags.NoPadding | TextFormatFlags.SingleLine); + + int width = Math.Max(label.MinimumSize.Width, measured.Width + 6); + int height = Math.Max(label.MinimumSize.Height, measured.Height); + + label.AutoSize = false; + label.MinimumSize = new Size(width, height); + label.Size = new Size(width, height); + } + + /// + /// 更新單一按鈕的最小尺寸,避免焦點加粗造成版面抖動 + /// + /// 目標按鈕。 + /// 目前 DPI 縮放比例。 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void UpdateSingleButtonMinimumSize(Button btn, float scale) + { + try + { + if (btn.IsDisposed) return; + + Font boldFont = _boldFont ?? MainForm.GetSharedA11yFont(DeviceDpi, FontStyle.Bold); + + DialogLayoutHelper.UpdateButtonMinimumSize(btn, boldFont, scale, 44, 44, 24, 16); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語編輯] UpdateSingleButtonMinimumSize 失敗:{ex.Message}"); + } + } + + /// + /// 依 DPI 更新最小尺寸,讓片語名稱/內容輸入框有更充足的可視範圍。 + /// + /// 是否強制重新計算,忽略 DPI 未變更的快取防呆。 + private void UpdateMinimumSize(bool forceRecalculate = false) + { + float currentDpi = DeviceDpi; + + if (!DialogLayoutHelper.TryBeginDpiLayout(currentDpi, ref _lastAppliedDpi, forceRecalculate)) + { + return; + } + + float scale = currentDpi / AppSettings.BaseDpi; + + UpdateCountLabelMinimumWidths(); + + int desiredMinWidth = (int)(BaseDialogMinWidth * scale); + + Rectangle workArea = Screen.GetWorkingArea(this); + + // 小尺寸螢幕保護:保留 40px 邊界,避免高縮放下最小尺寸超出可視區。 + (int maxFitWidth, int maxFitHeight) = DialogLayoutHelper.GetMaxFitSize(workArea); + + int + // 正常情況至少保留 320px 的可編輯寬度;若工作區本身更窄,則以工作區上限為準。 + minWidth = maxFitWidth >= 320 ? + Math.Clamp(desiredMinWidth, 320, maxFitWidth) : + maxFitWidth; + + int desiredMinHeight = (int)(340 * scale), + minH = Math.Min(desiredMinHeight, maxFitHeight); + + DialogLayoutHelper.ClampFormSize(this, minWidth, minH, maxFitWidth, maxFitHeight, ApplySmartPosition); + } + + /// + /// 保持對話框位於目前螢幕可視範圍內 + /// + private void ApplySmartPosition() + { + if (InputBoxLayoutManager.TryGetClampedLocation(this, out Point clampedLocation)) + { + Location = clampedLocation; + } + } +} diff --git a/src/InputBox/Core/Controls/PhraseEditDialog.cs b/src/InputBox/Core/Controls/PhraseEditDialog.cs index 3d8f88a..668e910 100644 --- a/src/InputBox/Core/Controls/PhraseEditDialog.cs +++ b/src/InputBox/Core/Controls/PhraseEditDialog.cs @@ -2,15 +2,11 @@ using InputBox.Core.Extensions; using InputBox.Core.Feedback; using InputBox.Core.Input; -using InputBox.Core.Interop; using InputBox.Core.Services; using InputBox.Core.Utilities; using InputBox.Resources; using Microsoft.Win32; -using System.ComponentModel; using System.Diagnostics; -using System.Media; -using System.Runtime.CompilerServices; namespace InputBox.Core.Controls; @@ -20,7 +16,7 @@ partial class DesignerBlocker { }; /// /// 片語編輯對話框(新增/編輯單一片語) /// -internal sealed class PhraseEditDialog : Form +internal sealed partial class PhraseEditDialog : Form { /// /// 片語編輯視窗的基準最小寬度(96 DPI) @@ -154,50 +150,6 @@ internal sealed class PhraseEditDialog : Form /// private float _lastAppliedDpi; - /// - /// 遊戲控制器 - /// - [Browsable(false)] - [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] - public IGamepadController? GamepadController - { - get => _gamepadController; - set - { - if (ReferenceEquals(_gamepadController, value)) - { - return; - } - - UnsubscribeGamepadEvents(); - - _gamepadController = value; - - if (_gamepadController != null) - { - GamepadFaceButtonProfile profile = GamepadFaceButtonProfile.GetActiveProfile(); - - _gamepadController.APressed += profile.ConfirmOnSouth ? HandleGamepadA : HandleBackOrClear; - _gamepadController.StartPressed += HandleOpenTouchKeyboardFromGamepad; - _gamepadController.BPressed += profile.ConfirmOnSouth ? HandleBackOrClear : HandleGamepadA; - _gamepadController.BackPressed += HandleCancel; - _gamepadController.LeftPressed += HandleLeft; - _gamepadController.LeftRepeat += HandleLeft; - _gamepadController.RightPressed += HandleRight; - _gamepadController.RightRepeat += HandleRight; - _gamepadController.UpPressed += HandleFieldPrev; - _gamepadController.DownPressed += HandleFieldNext; - _gamepadController.XPressed += HandleBackspace; - _gamepadController.YPressed += HandleOpenContextMenu; - _gamepadController.RSLeftPressed += HandleRSLeft; - _gamepadController.RSLeftRepeat += HandleRSLeft; - _gamepadController.RSRightPressed += HandleRSRight; - _gamepadController.RSRightRepeat += HandleRSRight; - _gamepadController.ConnectionChanged += HandleConnectionChanged; - } - } - } - /// /// 初始化片語編輯對話框 /// @@ -544,92 +496,6 @@ protected override void OnShown(EventArgs e) ApplyInputBoxStrongVisual(_txtName); } - /// - /// 建立控制項 Handle 後套用最小尺寸、系統事件訂閱與初始定位。 - /// - /// 控制項事件參數。 - protected override void OnHandleCreated(EventArgs e) - { - base.OnHandleCreated(e); - - UpdateMinimumSize(); - - SystemEvents.UserPreferenceChanged -= SystemEvents_UserPreferenceChanged; - SystemEvents.UserPreferenceChanged += SystemEvents_UserPreferenceChanged; - - this.SafeBeginInvoke(ApplySmartPosition); - } - - /// - /// DPI 變更時重新量測按鈕尺寸與對話框最小尺寸。 - /// - /// DPI 變更事件參數。 - protected override void OnDpiChanged(DpiChangedEventArgs e) - { - try - { - base.OnDpiChanged(e); - - this.SafeInvoke(() => - { - try - { - // DPI 變更後刷新字型快取引用(共享快取依 DPI 分開儲存,必須重新取得)。 - _a11yFont = MainForm.GetSharedA11yFont(DeviceDpi, FontStyle.Regular); - _boldFont = MainForm.GetSharedA11yFont(DeviceDpi, FontStyle.Bold); - Font = _a11yFont; - - // 輸入框使用 2.0× 倍率字型(已明確設定,不繼承 Form.Font,需手動更新)。 - Font sharedInputFont = MainForm.GetSharedA11yFont( - DeviceDpi, - FontStyle.Regular, - _a11yFont?.FontFamily, - 2.0f); - _txtName.Font = sharedInputFont; - _txtContent.Font = sharedInputFont; - - // 重新掛載眼動儀回饋,刷新 ButtonVisualState 中儲存的字型引用。 - _btnOk.AttachEyeTrackerFeedback( - baseDescription: Strings.Phrase_A11y_Btn_Confirm_Desc, - regularFont: _a11yFont, - boldFont: _boldFont, - formCt: _cts?.Token ?? CancellationToken.None); - - _btnCancel.AttachEyeTrackerFeedback( - baseDescription: Strings.Phrase_A11y_Btn_Cancel_Desc, - regularFont: _a11yFont, - boldFont: _boldFont, - formCt: _cts?.Token ?? CancellationToken.None); - - UpdateButtonMinimumSizes(); - UpdateMinimumSize(); - ApplySmartPosition(); - _btnOk.Invalidate(); - _btnCancel.Invalidate(); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] OnDpiChanged 延遲邏輯失敗:{ex.Message}"); - } - }); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] OnDpiChanged 失敗:{ex.Message}"); - } - } - - /// - /// 使用者結束調整視窗大小後重新套用智慧定位。 - /// - /// 事件參數。 - protected override void OnResizeEnd(EventArgs e) - { - base.OnResizeEnd(e); - - ApplySmartPosition(); - } - /// /// 處理命令鍵 /// @@ -716,109 +582,6 @@ protected override void OnFormClosing(FormClosingEventArgs e) } } - /// - /// Handle 銷毀時確保解除靜態系統事件訂閱 - /// - /// 控制項事件參數。 - protected override void OnHandleDestroyed(EventArgs e) - { - try - { - SystemEvents.UserPreferenceChanged -= SystemEvents_UserPreferenceChanged; - } - finally - { - base.OnHandleDestroyed(e); - } - } - - /// - /// 系統偏好設定變更時同步更新按鈕尺寸、最小尺寸與焦點視覺 - /// - /// 事件來源。 - /// 系統偏好設定事件參數。 - private void SystemEvents_UserPreferenceChanged(object? sender, UserPreferenceChangedEventArgs e) - { - try - { - if (e.Category is UserPreferenceCategory.Accessibility or - UserPreferenceCategory.Color or - UserPreferenceCategory.General) - { - this.SafeInvoke(() => - { - UpdateButtonMinimumSizes(); - UpdateMinimumSize(forceRecalculate: true); - ApplySmartPosition(); - - _btnOk.Invalidate(); - _btnCancel.Invalidate(); - - UpdateNameCharCount(); - UpdateContentCharCount(); - - TextBox? active = GetActiveTextBox(); - - if (active != null) - { - ApplyInputBoxStrongVisual(active); - } - }); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] SystemEvents_UserPreferenceChanged 失敗:{ex.Message}"); - } - } - - /// - /// A 鍵:按鈕 → PerformClick;輸入框為空時開啟觸控鍵盤;其餘情境走確認驗證 - /// - private void HandleGamepadA() => this.SafeInvoke(() => - { - try - { - if (ActiveControl is Button btn) - { - btn.PerformClick(); - return; - } - - TextBox? tb = GetActiveTextBox() ?? _txtName; - - if (tb != null && string.IsNullOrWhiteSpace(tb.Text)) - { - ShowTouchKeyboard(tb); - - return; - } - - HandleConfirm(); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] 控制器 A 鍵失敗:{ex.Message}"); - } - }); - - /// - /// Start 鍵:在輸入框焦點時直接開啟觸控式鍵盤(可在已有內容時修改) - /// - private void HandleOpenTouchKeyboardFromGamepad() => this.SafeInvoke(() => - { - try - { - TextBox? tb = GetActiveTextBox() ?? _txtName; - - ShowTouchKeyboard(tb); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] 控制器開啟觸控鍵盤失敗:{ex.Message}"); - } - }); - /// /// 驗證片語名稱與內容,驗證通過時以 OK 關閉對話框 /// @@ -869,110 +632,6 @@ private void HandleCancel() => this.SafeInvoke(() => } }); - /// - /// B 鍵:若焦點在輸入框且有內容則優先清空;否則執行取消 - /// - private void HandleBackOrClear() => this.SafeInvoke(() => - { - try - { - TextBox? tb = GetActiveTextBox(); - - if (tb != null) - { - if (tb.SelectionLength > 0) - { - tb.SelectedText = string.Empty; - - _rsSelectionAnchor = null; - - AnnounceA11y(Strings.Msg_InputCleared, interrupt: true); - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.ClearInput, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - - return; - } - - if (!string.IsNullOrEmpty(tb.Text)) - { - tb.Clear(); - - _rsSelectionAnchor = null; - - AnnounceA11y(Strings.Msg_InputCleared, interrupt: true); - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.ClearInput, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - - return; - } - } - - HandleCancel(); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] HandleBackOrClear 失敗:{ex.Message}"); - } - }); - - /// - /// 聚焦指定輸入框並非同步開啟觸控式鍵盤。 - /// - /// 要聚焦的輸入框。 - private void ShowTouchKeyboard(TextBox tb) - { - if (tb.CanFocus && !tb.Focused) - { - tb.Focus(); - } - - AnnounceA11y(Strings.A11y_Opening_Keyboard, interrupt: true); - - Task.Run(async () => - { - try - { - await Task.Delay(150, _cts?.Token ?? CancellationToken.None); - - if (TouchKeyboardService.IsVisible()) return; - - await this.SafeInvokeAsync(() => - { - try - { - bool opened = TouchKeyboardService.TryOpen(); - - if (opened) - { - FeedbackService.PlaySound(SystemSounds.Asterisk); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] 觸控鍵盤開啟失敗:{ex.Message}"); - } - }); - } - catch (OperationCanceledException) - { - - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] 開啟觸控鍵盤失敗:{ex.Message}"); - } - }, - _cts?.Token ?? CancellationToken.None).SafeFireAndForget(); - } - /// /// 片語名稱欄文字變更時,更新字數顯示並提供接近上限的回饋。 /// @@ -1016,1187 +675,20 @@ private void HandleContentKeyPress(object? sender, KeyPressEventArgs e) => HandleTextLimitKeyPress(_txtContent, AppSettings.MaxInputLength, ref _lastContentLimitWallUtc, e); /// - /// 取得目前焦點所在的 TextBox(名稱或內容)。 - /// - /// 具有焦點的輸入框;兩者皆無焦點時回傳 null。 - private TextBox? GetActiveTextBox() - { - if (_txtName.Focused) - { - return _txtName; - } - - if (_txtContent.Focused) - { - return _txtContent; - } - - return null; - } - - /// - /// 輸入框取得焦點時套用強化焦點視覺。 - /// - /// 觸發事件的輸入框。 - /// 事件參數。 - private void HandleTextBoxEnter(object? sender, EventArgs eventArgs) - { - try - { - if (sender is TextBox textBox) - { - ApplyInputBoxStrongVisual(textBox); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] HandleTextBoxEnter 失敗:{ex.Message}"); - } - } - - /// - /// 輸入框失去焦點時還原一般視覺樣式。 + /// 從資源檔取得片語文字,若失敗則回傳後備文字 /// - /// 觸發事件的輸入框。 - /// 事件參數。 - private void HandleTextBoxLeave(object? sender, EventArgs eventArgs) + /// 資源鍵值。 + /// 找不到資源時使用的後備文字。 + /// 資源文字或後備文字。 + private static string GetPhraseTextOrFallback(string key, string fallback) { try { - if (sender is TextBox textBox) - { - ResetInputBoxVisual(textBox); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] HandleTextBoxLeave 失敗:{ex.Message}"); - } - } - - /// - /// 套用與主輸入框一致的強視覺焦點樣式(高對比優先,其次主題感知反轉)。 - /// - /// 要套用焦點樣式的輸入框。 - private static void ApplyInputBoxStrongVisual(TextBox textBox) - { - if (textBox.IsDisposed) - { - return; - } - - if (SystemInformation.HighContrast) - { - textBox.BackColor = SystemColors.Highlight; - textBox.ForeColor = SystemColors.HighlightText; - - return; - } - - if (textBox.IsDarkModeActive()) - { - // 深色模式:反轉為白底黑字。 - textBox.BackColor = Color.White; - textBox.ForeColor = Color.Black; - } - else - { - // 淺色模式:反轉為黑底白字。 - textBox.BackColor = Color.Black; - textBox.ForeColor = Color.White; - } - } - - /// - /// 還原輸入框為系統預設背景與前景色 - /// - /// 目標輸入框。 - private static void ResetInputBoxVisual(TextBox tb) - { - if (tb.IsDisposed) - { - return; + return Strings.ResourceManager.GetString(key, Strings.Culture) ?? fallback; } - - tb.BackColor = Color.Empty; - tb.ForeColor = Color.Empty; - } - - /// - /// 針對驗證失敗的輸入框提供焦點、音效、震動與視覺提示 - /// - /// 驗證失敗的輸入框。 - /// 要播報的錯誤訊息。 - private void NotifyValidationFailure(TextBox target, string message) - { - if (target.CanFocus && !target.Focused) - { - target.Focus(); - } - - AnnounceA11y(message, interrupt: true); - - FeedbackService.PlaySound(SystemSounds.Hand); - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.ActionFail, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - - FlashValidationCueAsync(target).SafeFireAndForget(); - } - - /// - /// 暫時閃爍輸入框以提供驗證失敗的視覺提示 - /// - /// 要閃爍提示的輸入框。 - /// 非同步作業。 - private async Task FlashValidationCueAsync(TextBox target) - { - if (target.IsDisposed || - !IsHandleCreated || - Interlocked.CompareExchange(ref _isFlashing, 1, 0) != 0) - { - return; - } - - // 僅在對話框生命週期仍有效時建立警示權杖,避免關閉途中留下失去連結的動畫。 - CancellationTokenSource? newAlertCts = _cts.TryCreateLinkedTokenSource(); - - if (newAlertCts == null) - { - Interlocked.Exchange(ref _isFlashing, 0); - - return; - } - - Interlocked.Exchange(ref _alertCts, newAlertCts)?.CancelAndDispose(); - - CancellationToken token = newAlertCts.Token; - - try - { - bool isDark = target.IsDarkModeActive(); - - // 決定警示色。 - Color alertColor = SystemInformation.HighContrast ? - SystemColors.Highlight : - (isDark ? Color.Firebrick : Color.DarkOrange); - - void ApplyAlertVisuals(float intensity) - { - if (target.IsDisposed || - !IsHandleCreated) - { - return; - } - - if (SystemInformation.HighContrast) - { - bool isAlert = intensity > 0.5f; - - Color hcBack = isAlert ? - alertColor : - SystemColors.Window, - hcFore = isAlert ? - SystemColors.HighlightText : - SystemColors.WindowText; - - target.BackColor = hcBack; - target.ForeColor = hcFore; - } - else - { - Color pureBase = isDark ? - Color.White : - Color.Black; - - int rN = (int)(pureBase.R + (alertColor.R - pureBase.R) * intensity), - gN = (int)(pureBase.G + (alertColor.G - pureBase.G) * intensity), - bN = (int)(pureBase.B + (alertColor.B - pureBase.B) * intensity); - - Color flashColor = Color.FromArgb(255, rN, gN, bN); - - // WCAG 相對亮度精確切換閾值(crossover L≈0.1791) - static float FLin(int c) - { - float f = c / 255f; - - return f <= 0.04045f ? - f / 12.92f : - MathF.Pow((f + 0.055f) / 1.055f, 2.4f); - } - - Color flashFore = (0.2126f * FLin(flashColor.R) + - 0.7152f * FLin(flashColor.G) + - 0.0722f * FLin(flashColor.B)) > 0.1791f ? - Color.Black : - Color.White; - - target.BackColor = flashColor; - target.ForeColor = flashFore; - } - } - - if (!SystemInformation.UIEffectsEnabled || - !AppSettings.Current.EnableAnimatedVisualAlerts) - { - await this.SafeInvokeAsync(() => ApplyAlertVisuals(1.0f)); - - await Task.Delay(800, token); - - return; - } - - using PeriodicTimer timer = new(TimeSpan.FromMilliseconds(AppSettings.TargetFrameTimeMs)); - - long startTime = Stopwatch.GetTimestamp(); - - while (await timer.WaitForNextTickAsync(token)) - { - long elapsedTicks = Stopwatch.GetTimestamp() - startTime; - - double elapsedMs = (double)elapsedTicks / Stopwatch.Frequency * 1000.0; - - if (elapsedMs >= AppSettings.PhotoSafeFrequencyMs) - { - break; - } - - double angle = elapsedMs / AppSettings.PhotoSafeFrequencyMs * 2.0 * Math.PI - (Math.PI / 2.0); - - float intensity = (float)((Math.Sin(angle) + 1.0) / 2.0); - - await this.SafeInvokeAsync(() => ApplyAlertVisuals(intensity)); - } - } - catch (OperationCanceledException) - { - // 正常取消。 - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] FlashValidationCueAsync 失敗:{ex.Message}"); - } - finally - { - Interlocked.Exchange(ref _isFlashing, 0); - Interlocked.Exchange(ref _alertCts, null)?.CancelAndDispose(); - - // 確保 UI 狀態還原。 - this.SafeInvoke(() => - { - try - { - if (target.IsDisposed || - !IsHandleCreated) - { - return; - } - - if (target.Focused) - { - ApplyInputBoxStrongVisual(target); - } - else - { - ResetInputBoxVisual(target); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] 驗證提示動畫 UI 還原失敗:{ex.Message}"); - } - }); - } - } - - /// - /// 從資源檔取得片語文字,若失敗則回傳後備文字 - /// - /// 資源鍵值。 - /// 找不到資源時使用的後備文字。 - /// 資源文字或後備文字。 - private static string GetPhraseTextOrFallback(string key, string fallback) - { - try - { - return Strings.ResourceManager.GetString(key, Strings.Culture) ?? fallback; - } - catch + catch { return fallback; } } - - /// - /// 游標左移(比照 MainForm.Gamepad.cs 的 MoveCursorLeft) - /// - private void HandleLeft() => this.SafeInvoke(() => - { - try - { - TextBox? tb = GetActiveTextBox(); - - if (tb == null) - { - // 焦點在按鈕區:D-Pad 左向在確認/取消按鈕間循環。 - if (_btnOk.Focused) - { - _btnCancel.Focus(); - AnnounceA11y(_btnCancel.AccessibleName ?? _btnCancel.Text, interrupt: true); - FeedbackService.VibrateAsync(_gamepadController, VibrationPatterns.CursorMove, _cts?.Token ?? CancellationToken.None).SafeFireAndForget(); - } - else if (_btnCancel.Focused) - { - _btnOk.Focus(); - AnnounceA11y(_btnOk.AccessibleName ?? _btnOk.Text, interrupt: true); - FeedbackService.VibrateAsync(_gamepadController, VibrationPatterns.CursorMove, _cts?.Token ?? CancellationToken.None).SafeFireAndForget(); - } - - return; - } - - bool hasSelection = tb.SelectionLength > 0; - - if (hasSelection || - tb.SelectionStart > 0) - { - if (hasSelection) - { - tb.SelectionLength = 0; - } - else if (_gamepadController?.IsLeftShoulderHeld == true) - { - tb.WordJump(false); - } - else - { - tb.SelectionStart--; - } - - tb.ScrollToCaret(); - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.CursorMove, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - else - { - FeedbackService.PlaySound(SystemSounds.Beep); - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.CursorMove, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] 左移失敗:{ex.Message}"); - } - }); - - /// - /// 游標右移(比照 MainForm.Gamepad.cs 的 MoveCursorRight) - /// - private void HandleRight() => this.SafeInvoke(() => - { - try - { - TextBox? tb = GetActiveTextBox(); - - if (tb == null) - { - // 焦點在按鈕區:D-Pad 右向在取消/確認按鈕間循環。 - if (_btnCancel.Focused) - { - _btnOk.Focus(); - AnnounceA11y(_btnOk.AccessibleName ?? _btnOk.Text, interrupt: true); - FeedbackService.VibrateAsync(_gamepadController, VibrationPatterns.CursorMove, _cts?.Token ?? CancellationToken.None).SafeFireAndForget(); - } - else if (_btnOk.Focused) - { - _btnCancel.Focus(); - AnnounceA11y(_btnCancel.AccessibleName ?? _btnCancel.Text, interrupt: true); - FeedbackService.VibrateAsync(_gamepadController, VibrationPatterns.CursorMove, _cts?.Token ?? CancellationToken.None).SafeFireAndForget(); - } - - return; - } - - bool hasSelection = tb.SelectionLength > 0; - - if (hasSelection || tb.SelectionStart < tb.Text.Length) - { - if (hasSelection) - { - tb.SelectionStart += tb.SelectionLength; - tb.SelectionLength = 0; - } - else if (_gamepadController?.IsLeftShoulderHeld == true) - { - tb.WordJump(true); - } - else - { - tb.SelectionStart++; - } - - tb.ScrollToCaret(); - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.CursorMove, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - else - { - FeedbackService.PlaySound(SystemSounds.Beep); - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.CursorMove, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] 右移失敗:{ex.Message}"); - } - }); - - /// - /// 上方向鍵:在名稱欄位與內容欄位和按鈕之間切換焦點 - /// - private void HandleFieldPrev() => this.SafeInvoke(() => - { - try - { - if (_txtContent.Focused) - { - _txtName.Focus(); - } - else if (_btnOk.Focused) - { - _txtContent.Focus(); - } - else if (_btnCancel.Focused) - { - _btnOk.Focus(); - } - else if (_txtName.Focused) - { - _btnCancel.Focus(); - } - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.CursorMove, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] 欄位向前失敗:{ex.Message}"); - } - }); - - /// - /// 下方向鍵:在名稱欄位、內容欄位與按鈕之間循環焦點 - /// - private void HandleFieldNext() => this.SafeInvoke(() => - { - try - { - if (_txtName.Focused) - { - _txtContent.Focus(); - } - else if (_txtContent.Focused) - { - _btnOk.Focus(); - } - else if (_btnOk.Focused) - { - _btnCancel.Focus(); - } - else if (_btnCancel.Focused) - { - _txtName.Focus(); - } - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.CursorMove, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] 欄位向後失敗:{ex.Message}"); - } - }); - - /// - /// X 鍵:刪除選取文字或游標前一字元(比照 MainForm.Gamepad.cs 的 X 鍵刪除邏輯) - /// - private void HandleBackspace() => this.SafeInvoke(() => - { - try - { - TextBox? tb = GetActiveTextBox(); - - if (tb == null || - tb.ReadOnly) - { - return; - } - - if (tb.SelectionLength > 0) - { - tb.SelectedText = string.Empty; - } - else if (tb.SelectionStart > 0) - { - int pos = tb.SelectionStart; - - tb.Select(pos - 1, 1); - tb.SelectedText = string.Empty; - } - else - { - FeedbackService.PlaySound(SystemSounds.Beep); - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.CursorMove, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] 退格失敗:{ex.Message}"); - } - }); - - /// - /// 右搖桿左推:擴張選取範圍向左(比照 MainForm.Gamepad.cs 的 ExpandSelection) - /// - private void HandleRSLeft() => this.SafeInvoke(() => - { - try - { - ExpandSelection(-1); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] 右摘桿左移失敗:{ex.Message}"); - } - }); - - /// - /// 右搖桿右推:擴張選取範圍向右 - /// - private void HandleRSRight() => this.SafeInvoke(() => - { - try - { - ExpandSelection(1); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] 右摘桿右移失敗:{ex.Message}"); - } - }); - - /// - /// Y 鍵:開啟焦點 TextBox 的原生右鍵選單 - /// - 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) - { - return; - } - - // 在游標位置附近顯示內建右鍵選單。 - Point caretPos = tb.GetPositionFromCharIndex(tb.SelectionStart); - - tb.ContextMenuStrip?.Show(tb, caretPos); - - // TextBox 沒有 ContextMenuStrip 時,透過模擬 Shift+F10 觸發原生選單。 - if (tb.ContextMenuStrip == null) - { - User32.SendMessage(tb.Handle, 0x007B, tb.Handle, unchecked((nint)0xFFFFFFFF)); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] 開啟右鍵選單失敗:{ex.Message}"); - } - }); - - /// - /// 在重建 PhraseEditDialog surface 前關閉目前 TextBox 的右鍵選單。 - /// - private void CloseActiveTextBoxContextMenu() - { - GetActiveTextBox()?.ContextMenuStrip?.Close(); - } - - /// - /// 擴張或縮減文字選取範圍(比照 MainForm.Gamepad.cs 的 ExpandSelection) - /// - /// 方向,正數表示向右擴張,負數表示向左擴張。 - private void ExpandSelection(int direction) - { - TextBox? tb = GetActiveTextBox(); - - if (tb == null) - { - return; - } - - if (tb.SelectionLength == 0 || - _rsSelectionAnchor == null || - (tb.SelectionStart != _rsSelectionAnchor.Value && - tb.SelectionStart + tb.SelectionLength != _rsSelectionAnchor.Value)) - { - _rsSelectionAnchor = tb.SelectionStart; - } - - int anchor = _rsSelectionAnchor.Value, - caret = (tb.SelectionStart == anchor) ? - (anchor + tb.SelectionLength) : - tb.SelectionStart; - - int safeDirection = Math.Sign(direction); - bool wordGranularity = _gamepadController?.IsLeftShoulderHeld == true || - _gamepadController?.IsRightShoulderHeld == true; - - int newCaret = wordGranularity ? - GetWordSelectionCaretTarget(tb, caret, safeDirection) : - Math.Clamp(caret + safeDirection, 0, tb.TextLength); - - if (newCaret == caret) - { - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.ActionFail, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - - return; - } - - // 使用 Win32 EM_SETSEL 正確設定選取範圍(含反向選取)。 - User32.SendMessage(tb.Handle, 0x00B1, anchor, newCaret); - tb.ScrollToCaret(); - - PlaySelectionFeedback(safeDirection, wordGranularity); - } - - /// - /// 推進某類觸覺回饋的 burst 等級,用於快速連發時做輕量阻尼。 - /// - /// 上次觸發的 UTC 時間戳(ref,會被更新)。 - /// 目前 burst 等級(ref,會被累加或重設)。 - /// 視為快速連發的時間視窗(毫秒)。 - /// 更新後的 burst 等級(0 ~ 3)。 - private static int AdvanceFeedbackBurst(ref DateTime lastUtc, ref int burstLevel, int fastWindowMs) - { - DateTime now = DateTime.UtcNow; - burstLevel = (now - lastUtc).TotalMilliseconds <= fastWindowMs ? - Math.Min(burstLevel + 1, 3) : - 0; - lastUtc = now; - - return burstLevel; - } - - /// - /// 依剩餘字元數分級,僅在接近上限時回傳有效 bucket。 - /// - /// 距離上限的剩餘字元數。 - /// 警示等級(0 ~ 3);超過警示閾值時回傳 -1。 - private static int GetTextLimitWarningBucket(int remainingCharacters) - { - return remainingCharacters switch - { - <= 0 => 3, - <= 2 => 2, - <= 5 => 1, - <= TextLimitWarningThreshold => 0, - _ => -1 - }; - } - - /// - /// 當片語欄位長度變動時,提供接近字數上限的物理預警與硬牆回饋。 - /// - /// 監控中的輸入框。 - /// 欄位的最大字元數限制。 - /// 上次觀察到的長度(ref,會被更新)。 - /// 上次警示的 bucket 等級(ref,會被更新)。 - private void HandleTextLimitFeedbackFromLengthChange( - TextBox textBox, - int maxLength, - ref int lastObservedLength, - ref int lastWarningBucket) - { - if (textBox.IsDisposed) - { - return; - } - - int currentLength = textBox.TextLength; - - if (currentLength < lastObservedLength) - { - lastObservedLength = currentLength; - lastWarningBucket = currentLength >= maxLength - TextLimitWarningThreshold ? - GetTextLimitWarningBucket(maxLength - currentLength) : - -1; - return; - } - - int remainingCharacters = maxLength - currentLength; - - if (remainingCharacters > TextLimitWarningThreshold) - { - lastObservedLength = currentLength; - lastWarningBucket = -1; - return; - } - - int currentBucket = GetTextLimitWarningBucket(remainingCharacters); - - if (currentLength > lastObservedLength && - (currentBucket != lastWarningBucket || remainingCharacters <= 2)) - { - if (remainingCharacters <= 0) - { - FeedbackService.PlaySound(SystemSounds.Beep); - } - - FeedbackService.VibrateSequenceAsync( - _gamepadController, - VibrationPatterns.GetTextLimitSequence( - remainingCharacters, - _gamepadController?.VibrationMotorSupport ?? VibrationMotorSupport.None), - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - - lastObservedLength = currentLength; - lastWarningBucket = currentBucket; - } - - /// - /// 使用者在字數已滿時仍嘗試輸入一般字元,播放硬牆回饋。 - /// - /// 目標輸入框。 - /// 欄位的最大字元數限制。 - /// 上次觸發硬牆回饋的 UTC 時間戳(ref,用於節流)。 - /// 按鍵事件引數。 - private void HandleTextLimitKeyPress(TextBox textBox, int maxLength, ref DateTime lastWallUtc, KeyPressEventArgs e) - { - if (textBox.IsDisposed || - char.IsControl(e.KeyChar) || - textBox.TextLength < maxLength) - { - return; - } - - if ((DateTime.UtcNow - lastWallUtc).TotalMilliseconds < RepeatedBoundaryFeedbackThrottleMs) - { - return; - } - - lastWallUtc = DateTime.UtcNow; - FeedbackService.PlaySound(SystemSounds.Beep); - FeedbackService.VibrateSequenceAsync( - _gamepadController, - VibrationPatterns.GetTextLimitSequence( - 0, - _gamepadController?.VibrationMotorSupport ?? VibrationMotorSupport.None), - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - - /// - /// 根據選取粒度與速度播放不同的右搖桿文字選取回饋。 - /// - /// 選取方向;負值為向左,正值為向右。 - /// 是否為單字粒度選取。 - private void PlaySelectionFeedback(int direction, bool wordGranularity) - { - IGamepadController? controller = _gamepadController; - - if (controller == null || - !controller.IsConnected) - { - return; - } - - int burstLevel = wordGranularity ? - 0 : - AdvanceFeedbackBurst(ref _lastSelectionFeedbackUtc, ref _selectionFeedbackBurstLevel, SelectionBurstWindowMs); - - FeedbackService.VibrateSequenceAsync( - controller, - VibrationPatterns.GetSelectionSequence(direction, wordGranularity, burstLevel, controller.VibrationMotorSupport), - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - - FeedbackService.PlaySelectionCue(wordGranularity, burstLevel); - } - - /// - /// 以現有的單字跳轉邏輯推算右搖桿在單字粒度下的選取目標位置。 - /// - /// 目標 TextBox。 - /// 目前游標位置(字元索引)。 - /// 方向;正值為向右,負值為向左。 - /// 跳轉後的游標目標位置(字元索引)。 - private static int GetWordSelectionCaretTarget(TextBox textBox, int caret, int direction) - { - int originalStart = textBox.SelectionStart; - int originalLength = textBox.SelectionLength; - - try - { - textBox.SelectionStart = Math.Clamp(caret, 0, textBox.TextLength); - textBox.SelectionLength = 0; - textBox.WordJump(direction > 0); - - return textBox.SelectionStart; - } - finally - { - textBox.SelectionStart = originalStart; - textBox.SelectionLength = originalLength; - } - } - - /// - /// 控制器重新連線後恢復輪詢狀態。 - /// - /// 新的控制器連線狀態。 - private void HandleConnectionChanged(bool connected) - { - try - { - if (connected) - { - _gamepadController?.Resume(); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] 控制器連線變更:{ex.Message}"); - } - } - - /// - /// 解除目前片語編輯對話框所綁定的控制器事件 - /// - private void UnsubscribeGamepadEvents() - { - try - { - if (_gamepadController != null) - { - _gamepadController.APressed -= HandleGamepadA; - _gamepadController.APressed -= HandleBackOrClear; - _gamepadController.StartPressed -= HandleOpenTouchKeyboardFromGamepad; - _gamepadController.BPressed -= HandleGamepadA; - _gamepadController.BPressed -= HandleBackOrClear; - _gamepadController.BackPressed -= HandleCancel; - _gamepadController.LeftPressed -= HandleLeft; - _gamepadController.LeftRepeat -= HandleLeft; - _gamepadController.RightPressed -= HandleRight; - _gamepadController.RightRepeat -= HandleRight; - _gamepadController.UpPressed -= HandleFieldPrev; - _gamepadController.DownPressed -= HandleFieldNext; - _gamepadController.XPressed -= HandleBackspace; - _gamepadController.YPressed -= HandleOpenContextMenu; - _gamepadController.RSLeftPressed -= HandleRSLeft; - _gamepadController.RSLeftRepeat -= HandleRSLeft; - _gamepadController.RSRightPressed -= HandleRSRight; - _gamepadController.RSRightRepeat -= HandleRSRight; - _gamepadController.ConnectionChanged -= HandleConnectionChanged; - } - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] UnsubscribeGamepadEvents 失敗:{ex.Message}"); - } - } - - /// - /// 更新片語名稱字元數提示標籤({current}/{max}),近上限時顯示橙色。 - /// - private void UpdateNameCharCount() - { - if (_lblNameCount == null || _lblNameCount.IsDisposed) - { - return; - } - - int len = _txtName.TextLength; - int max = AppSettings.MaxPhraseNameLength; - string countLabel = GetPhraseTextOrFallback("Phrase_Edit_Name_Count", "Name length: "); - string countText = $"{countLabel}{len}/{max}"; - - _lblNameCount.Text = countText; - _lblNameCount.AccessibleName = countText; - _lblNameCount.AccessibleDescription = $"{Strings.Phrase_A11y_Edit_Name_Desc} {countText}"; - - if (SystemInformation.HighContrast) - { - _lblNameCount.ForeColor = Color.Empty; - - return; - } - - _lblNameCount.ForeColor = len >= max - 10 ? - Color.DarkOrange : - Color.Empty; - } - - /// - /// 更新片語內容字元數提示標籤({current}/{max}),近上限時顯示橙色。 - /// - private void UpdateContentCharCount() - { - if (_lblContentCount == null || _lblContentCount.IsDisposed) - { - return; - } - - int len = _txtContent.TextLength; - int max = AppSettings.MaxInputLength; - string countLabel = GetPhraseTextOrFallback("Phrase_Edit_Content_Count", "Content length: "); - string countText = $"{countLabel}{len}/{max}"; - - _lblContentCount.Text = countText; - _lblContentCount.AccessibleName = countText; - _lblContentCount.AccessibleDescription = $"{Strings.Phrase_A11y_Edit_Content_Desc} {countText}"; - - if (SystemInformation.HighContrast) - { - _lblContentCount.ForeColor = Color.Empty; - - return; - } - - _lblContentCount.ForeColor = len >= max - 50 ? - Color.DarkOrange : - Color.Empty; - } - - /// - /// 更新按鈕最小尺寸(抗抖動 + WCAG 2.5.5 AAA 44×44) - /// - private void UpdateButtonMinimumSizes() - { - float scale = DeviceDpi / AppSettings.BaseDpi; - - UpdateSingleButtonMinimumSize(_btnOk, scale); - UpdateSingleButtonMinimumSize(_btnCancel, scale); - } - - /// - /// 預先鎖定動態字數標籤的最小寬度,避免數值變化時造成版面抖動。 - /// - private void UpdateCountLabelMinimumWidths() - { - UpdateSingleCountLabelMinimumWidth( - _lblNameCount, - GetPhraseTextOrFallback("Phrase_Edit_Name_Count", "Name length: "), - AppSettings.MaxPhraseNameLength); - UpdateSingleCountLabelMinimumWidth( - _lblContentCount, - GetPhraseTextOrFallback("Phrase_Edit_Content_Count", "Content length: "), - AppSettings.MaxInputLength); - } - - /// - /// 鎖定單一動態標籤的寬度,讓最長狀態文字也不會改變物理尺寸。 - /// - /// 要鎖定寬度的標籤;為 null 時略過。 - /// 標籤前綴文字,用於計算最寬狀態。 - /// 欄位的最大值,用於計算最寬文字。 - private static void UpdateSingleCountLabelMinimumWidth(Label? label, string labelPrefix, int maxValue) - { - if (label == null || - label.IsDisposed) - { - return; - } - - string widestText = $"{labelPrefix}{maxValue}/{maxValue}"; - Size measured = TextRenderer.MeasureText( - widestText, - label.Font, - Size.Empty, - TextFormatFlags.NoPadding | TextFormatFlags.SingleLine); - - int width = Math.Max(label.MinimumSize.Width, measured.Width + 6); - int height = Math.Max(label.MinimumSize.Height, measured.Height); - - label.AutoSize = false; - label.MinimumSize = new Size(width, height); - label.Size = new Size(width, height); - } - - /// - /// 更新單一按鈕的最小尺寸,避免焦點加粗造成版面抖動 - /// - /// 目標按鈕。 - /// 目前 DPI 縮放比例。 - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void UpdateSingleButtonMinimumSize(Button btn, float scale) - { - try - { - if (btn.IsDisposed) return; - - Font boldFont = _boldFont ?? MainForm.GetSharedA11yFont(DeviceDpi, FontStyle.Bold); - - DialogLayoutHelper.UpdateButtonMinimumSize(btn, boldFont, scale, 44, 44, 24, 16); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] UpdateSingleButtonMinimumSize 失敗:{ex.Message}"); - } - } - - /// - /// 廣播無障礙訊息 - /// - /// 要廣播的訊息 - /// 是否中斷目前的廣播 - private void AnnounceA11y(string message, bool interrupt = false) - { - if (IsDisposed || - string.IsNullOrEmpty(message)) - { - return; - } - - if (Owner is MainForm mainForm) - { - mainForm.AnnounceA11y(message, interrupt); - } - else if (Owner is PhraseManagerDialog phraseManager) - { - // 嘗試寫往主視窗。 - if (phraseManager.Owner is MainForm main) - { - main.AnnounceA11y(message, interrupt); - - return; - } - } - - // 本地備援 - long currentId = Interlocked.Increment(ref _a11yDebounceId); - - Task.Run(async () => - { - try - { - await Task.Delay(AppSettings.AudioDuckingDelayMs, _cts?.Token ?? CancellationToken.None); - - if (Interlocked.Read(ref _a11yDebounceId) == currentId && - !IsDisposed && - IsHandleCreated) - { - await this.SafeInvokeAsync(() => - _announcer.Announce(message, interrupt && AppSettings.Current.A11yInterruptEnabled)); - } - } - catch (OperationCanceledException) - { - - } - catch (Exception ex) - { - Debug.WriteLine($"[片語編輯] A11y 廣播失敗:{ex.Message}"); - } - }, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - - /// - /// 依 DPI 更新最小尺寸,讓片語名稱/內容輸入框有更充足的可視範圍。 - /// - /// 是否強制重新計算,忽略 DPI 未變更的快取防呆。 - private void UpdateMinimumSize(bool forceRecalculate = false) - { - float currentDpi = DeviceDpi; - - if (!DialogLayoutHelper.TryBeginDpiLayout(currentDpi, ref _lastAppliedDpi, forceRecalculate)) - { - return; - } - - float scale = currentDpi / AppSettings.BaseDpi; - - UpdateCountLabelMinimumWidths(); - - int desiredMinWidth = (int)(BaseDialogMinWidth * scale); - - Rectangle workArea = Screen.GetWorkingArea(this); - - // 小尺寸螢幕保護:保留 40px 邊界,避免高縮放下最小尺寸超出可視區。 - (int maxFitWidth, int maxFitHeight) = DialogLayoutHelper.GetMaxFitSize(workArea); - - int - // 正常情況至少保留 320px 的可編輯寬度;若工作區本身更窄,則以工作區上限為準。 - minWidth = maxFitWidth >= 320 ? - Math.Clamp(desiredMinWidth, 320, maxFitWidth) : - maxFitWidth; - - int desiredMinHeight = (int)(340 * scale), - minH = Math.Min(desiredMinHeight, maxFitHeight); - - DialogLayoutHelper.ClampFormSize(this, minWidth, minH, maxFitWidth, maxFitHeight, ApplySmartPosition); - } - - /// - /// 保持對話框位於目前螢幕可視範圍內 - /// - private void ApplySmartPosition() - { - if (InputBoxLayoutManager.TryGetClampedLocation(this, out Point clampedLocation)) - { - Location = clampedLocation; - } - } -} \ No newline at end of file +} diff --git a/src/InputBox/Core/Controls/PhraseManagerDialog.A11y.cs b/src/InputBox/Core/Controls/PhraseManagerDialog.A11y.cs new file mode 100644 index 0000000..56addef --- /dev/null +++ b/src/InputBox/Core/Controls/PhraseManagerDialog.A11y.cs @@ -0,0 +1,64 @@ +using InputBox.Core.Configuration; +using InputBox.Core.Extensions; +using System.Diagnostics; + +namespace InputBox.Core.Controls; + +// 阻擋設計工具。 +partial class DesignerBlocker { }; + +/// +/// 片語管理對話框(無障礙輔助功能分部)。 +/// 本分部檔案包含 A11y 廣播成員。 +/// +internal sealed partial class PhraseManagerDialog +{ + /// + /// 內部 A11y 廣播 + /// + /// 要廣播的無障礙訊息文字。 + /// 設為 可中斷目前朗讀(需設定允許中斷)。 + private void AnnounceA11y(string message, bool interrupt = false) + { + if (IsDisposed || + string.IsNullOrEmpty(message)) + { + return; + } + + if (Owner is MainForm mainForm) + { + mainForm.AnnounceA11y(message, interrupt); + } + else + { + long currentId = Interlocked.Increment(ref _a11yDebounceId); + + Task.Run(async () => + { + try + { + await Task.Delay(AppSettings.AudioDuckingDelayMs, _cts?.Token ?? CancellationToken.None); + + if (Interlocked.Read(ref _a11yDebounceId) == currentId && + !IsDisposed && + IsHandleCreated) + { + await this.SafeInvokeAsync(() => + _announcer.Announce(message, interrupt && AppSettings.Current.A11yInterruptEnabled)); + } + } + catch (OperationCanceledException) + { + + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] A11y 廣播失敗:{ex.Message}"); + } + }, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + } +} diff --git a/src/InputBox/Core/Controls/PhraseManagerDialog.Gamepad.cs b/src/InputBox/Core/Controls/PhraseManagerDialog.Gamepad.cs new file mode 100644 index 0000000..7fc2fbf --- /dev/null +++ b/src/InputBox/Core/Controls/PhraseManagerDialog.Gamepad.cs @@ -0,0 +1,686 @@ +using InputBox.Core.Configuration; +using InputBox.Core.Extensions; +using InputBox.Core.Feedback; +using InputBox.Core.Input; +using InputBox.Core.Services; +using InputBox.Core.Utilities; +using InputBox.Resources; +using System.ComponentModel; +using System.Diagnostics; +using System.Media; + +namespace InputBox.Core.Controls; + +// 阻擋設計工具。 +partial class DesignerBlocker { }; + +/// +/// 片語管理對話框(遊戲控制器事件處理分部)。 +/// 本分部檔案包含遊戲控制器事件訂閱、清單導覽、片語快捷跳轉與按鈕焦點循環等成員。 +/// +internal sealed partial class PhraseManagerDialog +{ + /// + /// 設定控制器實作,並訂閱事件 + /// + [Browsable(false)] + [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] + public IGamepadController? GamepadController + { + get => _gamepadController; + set + { + if (ReferenceEquals(_gamepadController, value)) + { + return; + } + + UnsubscribeGamepadEvents(); + + _gamepadController = value; + + SubscribeGamepadEvents(); + } + } + + /// + /// 判斷片語管理對話框目前是否應接手控制器輸入。 + /// + /// 若對話框可安全處理控制器操作則回傳 true。 + private bool CanHandleGamepadInput() + { + return Visible && + !IsDisposed && + (ActiveForm == this || + ContainsFocus || + _lstPhrases.Focused || + _lstPhrases.ContainsFocus || + IsButtonAreaFocused()); + } + + /// + /// 控制器向上輸入時在清單項目或按鈕焦點之間移動 + /// + private void HandleUp() => this.SafeInvoke(() => + { + try + { + if (!CanHandleGamepadInput()) + { + return; + } + + // 焦點在按鈕上時,D-Pad 上下切換焦點。 + if (IsButtonAreaFocused()) + { + CycleFocus(forward: false); + return; + } + + if (_lstPhrases.Items.Count > 0) + { + int newIdx = _lstPhrases.SelectedIndex <= 0 ? + _lstPhrases.Items.Count - 1 : + _lstPhrases.SelectedIndex - 1; + + _lstPhrases.SelectedIndex = newIdx; + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.CursorMove, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] HandleUp 失敗:{ex.Message}"); + } + }); + + /// + /// 控制器向下輸入時在清單項目或按鈕焦點之間移動 + /// + private void HandleDown() => this.SafeInvoke(() => + { + try + { + if (!CanHandleGamepadInput()) + { + return; + } + + // 焦點在按鈕上時,D-Pad 上下切換焦點。 + if (IsButtonAreaFocused()) + { + CycleFocus(forward: true); + + return; + } + + if (_lstPhrases.Items.Count > 0) + { + int newIdx = _lstPhrases.SelectedIndex >= _lstPhrases.Items.Count - 1 ? + 0 : + _lstPhrases.SelectedIndex + 1; + + _lstPhrases.SelectedIndex = newIdx; + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.CursorMove, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] HandleDown 失敗:{ex.Message}"); + } + }); + + /// + /// 控制器 A 鍵依目前焦點執行按鈕、插入片語或新增片語 + /// + private void HandleGamepadA() => this.SafeInvoke(() => + { + try + { + if (!CanHandleGamepadInput()) + { + return; + } + + // 如果焦點在按鈕上,執行該按鈕的動作。 + if (TryGetFocusedButton(out Button? focusedBtn) && + focusedBtn is { Enabled: true }) + { + focusedBtn.PerformClick(); + } + else if (_lstPhrases.Focused && + _lstPhrases.SelectedIndex >= 0) + { + InsertSelectedPhrase(); + } + else + { + AddPhrase(); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] HandleGamepadA 失敗:{ex.Message}"); + } + }); + + /// + /// 控制器取消動作時關閉片語管理對話框 + /// + private void HandleClose() => this.SafeInvoke(() => + { + try + { + if (!CanHandleGamepadInput()) + { + return; + } + + Close(); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] HandleClose 失敗:{ex.Message}"); + } + }); + + /// + /// 控制器刪除動作時移除目前選取的片語 + /// + private void HandleDelete() => this.SafeInvoke(() => + { + try + { + if (!CanHandleGamepadInput()) + { + return; + } + + DeleteSelectedPhrase(); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] HandleDelete 失敗:{ex.Message}"); + } + }); + + /// + /// 控制器新增動作時開啟片語建立流程 + /// + private void HandleAdd() => this.SafeInvoke(() => + { + try + { + if (GamescopeSurfaceRecovery.TryRecoverFromGamepadChord( + this, + RecreateHandle, + _gamepadController, + context: "PhraseManagerDialog Gamescope surface recovery 失敗")) + { + return; + } + + if (!CanHandleGamepadInput()) + { + return; + } + + AddPhrase(); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] HandleAdd 失敗:{ex.Message}"); + } + }); + + /// + /// LB/RB 捷徑切換左側片語清單中的上一筆或下一筆,讓使用者可快速瀏覽而不影響右側按鈕區焦點邏輯。 + /// + /// -1 代表上一筆,+1 代表下一筆。 + private void HandlePhraseShortcutStep(int delta) => this.SafeInvoke(() => + { + try + { + if (!CanHandleGamepadInput() || + _lstPhrases.Items.Count == 0) + { + return; + } + + int currentIndex = _lstPhrases.SelectedIndex < 0 ? + 0 : + _lstPhrases.SelectedIndex; + int targetIndex = Math.Clamp(currentIndex + delta, 0, _lstPhrases.Items.Count - 1); + + _lstPhrases.Focus(); + + if (targetIndex == _lstPhrases.SelectedIndex) + { + FeedbackService.PlaySound(SystemSounds.Beep); + + return; + } + + _lstPhrases.SelectedIndex = targetIndex; + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.CursorMove, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] HandlePhraseShortcutStep 失敗:{ex.Message}"); + } + }); + + /// + /// LT/RT 捷徑直接跳到左側片語清單的第一筆或最後一筆,加快大量片語時的巡覽效率。 + /// + /// true 表示跳到最後一筆;false 表示跳到第一筆。 + private void HandlePhraseShortcutBoundary(bool last) => this.SafeInvoke(() => + { + try + { + if (!CanHandleGamepadInput() || + _lstPhrases.Items.Count == 0) + { + return; + } + + int targetIndex = last ? + _lstPhrases.Items.Count - 1 : + 0; + + _lstPhrases.Focus(); + + if (targetIndex == _lstPhrases.SelectedIndex) + { + FeedbackService.PlaySound(SystemSounds.Beep); + + return; + } + + _lstPhrases.SelectedIndex = targetIndex; + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.CursorMove, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] HandlePhraseShortcutBoundary 失敗:{ex.Message}"); + } + }); + + /// + /// 控制器 LB 捷徑切換到上一個片語。 + /// + private void HandlePreviousPhraseShortcut() => HandlePhraseShortcutStep(-1); + + /// + /// 控制器 RB 捷徑切換到下一個片語。 + /// + private void HandleNextPhraseShortcut() => HandlePhraseShortcutStep(1); + + /// + /// 控制器 LT 捷徑直接跳到第一個片語。 + /// + private void HandleFirstPhraseShortcut() => HandlePhraseShortcutBoundary(last: false); + + /// + /// 控制器 RT 捷徑直接跳到最後一個片語。 + /// + private void HandleLastPhraseShortcut() => HandlePhraseShortcutBoundary(last: true); + + /// + /// 控制器左向輸入時回到清單或將片語上移 + /// + private void HandleMoveUp() => this.SafeInvoke(() => + { + try + { + if (!CanHandleGamepadInput()) + { + return; + } + + // 左方向鍵:若目前在右側按鈕區,回到清單。 + if (IsButtonAreaFocused()) + { + _lstPhrases.Focus(); + + if (_lstPhrases.SelectedIndex >= 0) + { + IReadOnlyList phrases = _phraseService.Phrases; + + if (_lstPhrases.SelectedIndex < phrases.Count) + { + AnnounceA11y( + AppSettings.Current.IsPrivacyMode ? + Strings.Phrase_A11y_Selected_PrivacySafe : + string.Format(Strings.Phrase_A11y_Selected, phrases[_lstPhrases.SelectedIndex].Name), + interrupt: true); + } + } + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.CursorMove, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + + return; + } + + MoveSelectedPhrase(-1); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] HandleMoveUp 失敗:{ex.Message}"); + } + }); + + /// + /// 控制器右向輸入時進入按鈕區或將片語下移 + /// + private void HandleMoveDown() => this.SafeInvoke(() => + { + try + { + if (!CanHandleGamepadInput()) + { + return; + } + + // 右方向鍵:由清單進入右側按鈕區;若已在按鈕區則向後循環。 + if (_lstPhrases.Focused || + _lstPhrases.ContainsFocus) + { + FocusFirstActionButtonOrClose(); + + return; + } + + if (IsButtonAreaFocused()) + { + CycleFocus(forward: true); + + return; + } + + MoveSelectedPhrase(1); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] HandleMoveDown 失敗:{ex.Message}"); + } + }); + + /// + /// 在清單與按鈕之間循環焦點 + /// + /// 是否向前循環焦點 + private void CycleFocus(bool forward) + { + // 建立焦點順序:清單 → 各按鈕(依面板順序)→ 關閉按鈕。 + List focusOrder = [_lstPhrases]; + + foreach (Control ctrl in _flpButtons.Controls) + { + if (ctrl is Button btn && + btn.Enabled && + btn.Visible) + { + focusOrder.Add(btn); + } + } + + if (_btnClose.Enabled && _btnClose.Visible) + { + focusOrder.Add(_btnClose); + } + + if (focusOrder.Count == 0) + { + return; + } + + // 找到目前焦點在列表中的位置。 + int currentIdx = -1; + + for (int i = 0; i < focusOrder.Count; i++) + { + if (focusOrder[i].Focused || + focusOrder[i].ContainsFocus) + { + currentIdx = i; + + break; + } + } + + int nextIdx; + + if (currentIdx < 0) + { + nextIdx = forward ? + 0 : + focusOrder.Count - 1; + } + else + { + nextIdx = forward ? + (currentIdx + 1) % focusOrder.Count : + (currentIdx - 1 + focusOrder.Count) % focusOrder.Count; + } + + focusOrder[nextIdx].Focus(); + + // 播報焦點目標。 + string? name = focusOrder[nextIdx].AccessibleName ?? focusOrder[nextIdx].Text; + + if (!string.IsNullOrEmpty(name)) + { + AnnounceA11y(name, interrupt: true); + } + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.CursorMove, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + + /// + /// 嘗試找出目前在按鈕區取得焦點的按鈕 + /// + /// 輸出的焦點按鈕。 + /// 若找到焦點按鈕則回傳 true。 + private bool TryGetFocusedButton(out Button? focusedBtn) + { + if (_btnClose.Focused || + _btnClose.ContainsFocus) + { + focusedBtn = _btnClose; + + return true; + } + + foreach (Control ctrl in _flpButtons.Controls) + { + if (ctrl is Button btn && + (btn.Focused || btn.ContainsFocus)) + { + focusedBtn = btn; + + return true; + } + } + + focusedBtn = null; + + return false; + } + + /// + /// 判斷目前焦點是否位於按鈕區 + /// + /// 若按鈕區有焦點則回傳 true。 + private bool IsButtonAreaFocused() => TryGetFocusedButton(out _); + + /// + /// 將焦點移至第一個可用動作按鈕,否則移至關閉按鈕。 + /// + private void FocusFirstActionButtonOrClose() + { + foreach (Control ctrl in _flpButtons.Controls) + { + if (ctrl is Button btn && + btn.Enabled && + btn.Visible) + { + btn.Focus(); + + AnnounceA11y(btn.AccessibleName ?? btn.Text, interrupt: true); + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.CursorMove, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + + return; + } + } + + if (_btnClose.Enabled && + _btnClose.Visible) + { + _btnClose.Focus(); + + AnnounceA11y(_btnClose.AccessibleName ?? _btnClose.Text, interrupt: true); + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.CursorMove, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + } + } + + /// + /// 控制器連線狀態變更時更新恢復狀態並廣播無障礙訊息。 + /// + /// 連線成功為 ,斷線為 。 + private void HandleGamepadConnectionChanged(bool connected) + { + try + { + if (connected) + { + _gamepadController?.Resume(); + } + + AnnounceA11y(connected ? + string.Format(Strings.A11y_Gamepad_Connected, _gamepadController?.DeviceName) : + string.Format(Strings.A11y_Gamepad_Disconnected, _gamepadController?.DeviceName)); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] 控制器連線變更處理失敗:{ex.Message}"); + } + } + + /// + /// 訂閱片語管理對話框使用的控制器事件 + /// + private void SubscribeGamepadEvents() + { + try + { + if (_gamepadController != null) + { + GamepadFaceButtonProfile profile = GamepadFaceButtonProfile.GetActiveProfile(); + + _gamepadController.UpPressed += HandleUp; + _gamepadController.UpRepeat += HandleUp; + _gamepadController.DownPressed += HandleDown; + _gamepadController.DownRepeat += HandleDown; + _gamepadController.APressed += profile.ConfirmOnSouth ? HandleGamepadA : HandleClose; + _gamepadController.StartPressed += HandleGamepadA; + _gamepadController.BPressed += profile.ConfirmOnSouth ? HandleClose : HandleGamepadA; + _gamepadController.BackPressed += HandleClose; + _gamepadController.XPressed += HandleDelete; + _gamepadController.YPressed += HandleAdd; + _gamepadController.LeftPressed += HandleMoveUp; + _gamepadController.LeftRepeat += HandleMoveUp; + _gamepadController.RightPressed += HandleMoveDown; + _gamepadController.RightRepeat += HandleMoveDown; + _gamepadController.LeftShoulderPressed += HandlePreviousPhraseShortcut; + _gamepadController.LeftShoulderRepeat += HandlePreviousPhraseShortcut; + _gamepadController.RightShoulderPressed += HandleNextPhraseShortcut; + _gamepadController.RightShoulderRepeat += HandleNextPhraseShortcut; + _gamepadController.LeftTriggerPressed += HandleFirstPhraseShortcut; + _gamepadController.RightTriggerPressed += HandleLastPhraseShortcut; + _gamepadController.ConnectionChanged += HandleGamepadConnectionChanged; + } + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] SubscribeGamepadEvents 失敗:{ex.Message}"); + } + } + + /// + /// 解除片語管理對話框使用的控制器事件 + /// + private void UnsubscribeGamepadEvents() + { + try + { + if (_gamepadController != null) + { + _gamepadController.UpPressed -= HandleUp; + _gamepadController.UpRepeat -= HandleUp; + _gamepadController.DownPressed -= HandleDown; + _gamepadController.DownRepeat -= HandleDown; + _gamepadController.APressed -= HandleGamepadA; + _gamepadController.APressed -= HandleClose; + _gamepadController.StartPressed -= HandleGamepadA; + _gamepadController.BPressed -= HandleGamepadA; + _gamepadController.BPressed -= HandleClose; + _gamepadController.BackPressed -= HandleClose; + _gamepadController.XPressed -= HandleDelete; + _gamepadController.YPressed -= HandleAdd; + _gamepadController.LeftPressed -= HandleMoveUp; + _gamepadController.LeftRepeat -= HandleMoveUp; + _gamepadController.RightPressed -= HandleMoveDown; + _gamepadController.RightRepeat -= HandleMoveDown; + _gamepadController.LeftShoulderPressed -= HandlePreviousPhraseShortcut; + _gamepadController.LeftShoulderRepeat -= HandlePreviousPhraseShortcut; + _gamepadController.RightShoulderPressed -= HandleNextPhraseShortcut; + _gamepadController.RightShoulderRepeat -= HandleNextPhraseShortcut; + _gamepadController.LeftTriggerPressed -= HandleFirstPhraseShortcut; + _gamepadController.RightTriggerPressed -= HandleLastPhraseShortcut; + _gamepadController.ConnectionChanged -= HandleGamepadConnectionChanged; + } + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] UnsubscribeGamepadEvents 失敗:{ex.Message}"); + } + } +} diff --git a/src/InputBox/Core/Controls/PhraseManagerDialog.Layout.cs b/src/InputBox/Core/Controls/PhraseManagerDialog.Layout.cs new file mode 100644 index 0000000..518ae97 --- /dev/null +++ b/src/InputBox/Core/Controls/PhraseManagerDialog.Layout.cs @@ -0,0 +1,339 @@ +using InputBox.Core.Configuration; +using InputBox.Core.Extensions; +using InputBox.Core.Services; +using InputBox.Core.Utilities; +using InputBox.Resources; +using Microsoft.Win32; +using System.Diagnostics; +using System.Runtime.CompilerServices; + +namespace InputBox.Core.Controls; + +// 阻擋設計工具。 +partial class DesignerBlocker { }; + +/// +/// 片語管理對話框(視覺與佈局分部)。 +/// 本分部檔案包含 DPI 與系統偏好變更處理、按鈕建立與字型、最小尺寸、透明度與智慧定位等成員。 +/// +internal sealed partial class PhraseManagerDialog +{ + protected override void OnHandleCreated(EventArgs e) + { + try + { + base.OnHandleCreated(e); + + ApplyFont(); + RefreshList(); + UpdateButtonStates(); + UpdateButtonMinimumSizes(); + UpdateMinimumSize(); + + this.SafeBeginInvoke(() => + { + try + { + UpdateOpacity(); + ApplySmartPosition(); + } + catch (Exception ex) + { + LoggerService.LogException(ex, "PhraseManagerDialog.OnHandleCreated 延遲邏輯失敗"); + + Debug.WriteLine($"[片語] OnHandleCreated 延遲邏輯失敗:{ex.Message}"); + } + }); + + SystemEvents.UserPreferenceChanged -= SystemEvents_UserPreferenceChanged; + SystemEvents.UserPreferenceChanged += SystemEvents_UserPreferenceChanged; + } + catch (Exception ex) + { + LoggerService.LogException(ex, "PhraseManagerDialog.OnHandleCreated 失敗"); + + Debug.WriteLine($"[片語] OnHandleCreated 失敗:{ex.Message}"); + } + } + + protected override void OnDpiChanged(DpiChangedEventArgs e) + { + try + { + base.OnDpiChanged(e); + + this.SafeInvoke(() => + { + try + { + ApplyFont(); + UpdateButtonMinimumSizes(); + UpdateMinimumSize(); + ApplySmartPosition(); + InvalidateAllButtons(); + } + catch (Exception ex) + { + LoggerService.LogException(ex, "PhraseManagerDialog.OnDpiChanged 延遲邏輯失敗"); + + Debug.WriteLine($"[片語] OnDpiChanged 失敗:{ex.Message}"); + } + }); + } + catch (Exception ex) + { + LoggerService.LogException(ex, "PhraseManagerDialog.OnDpiChanged 失敗"); + + Debug.WriteLine($"[片語] OnDpiChanged 失敗:{ex.Message}"); + } + } + + protected override void OnResizeEnd(EventArgs e) + { + base.OnResizeEnd(e); + + ApplySmartPosition(); + } + + /// + /// Handle 銷毀時解除靜態系統事件訂閱,避免遺留參考。 + /// + /// 控制項事件參數。 + protected override void OnHandleDestroyed(EventArgs e) + { + try + { + SystemEvents.UserPreferenceChanged -= SystemEvents_UserPreferenceChanged; + } + finally + { + base.OnHandleDestroyed(e); + } + } + + /// + /// 建立動作按鈕 + /// + /// 按鈕文字 + /// 輔助功能描述 + /// 快捷鍵字元 + /// 已設定樣式、快捷鍵與無障礙屬性的動作按鈕執行個體。 + private static Button CreateActionButton( + string text, + string a11yDesc, + char mnemonic) + { + Button btn = new() + { + Text = ControlExtensions.GetMnemonicText(text, mnemonic), + AutoSize = true, + FlatStyle = FlatStyle.Flat, + AccessibleName = text, + AccessibleDescription = a11yDesc, + AccessibleRole = AccessibleRole.PushButton, + BackColor = Color.Empty, + ForeColor = Color.Empty, + Margin = new Padding(2, 2, 2, 2), + Anchor = AnchorStyles.Left | AnchorStyles.Right + }; + btn.FlatAppearance.BorderSize = 0; + + // 暫存 base description 到 Tag 供後續 AttachEyeTrackerFeedback 使用。 + btn.Tag = a11yDesc; + + return btn; + } + + /// + /// 套用字型(Regular + Bold)並掛載眼動儀擴充 + /// + private void ApplyFont() + { + _a11yFont = MainForm.GetSharedA11yFont(DeviceDpi, FontStyle.Regular); + _boldFont = MainForm.GetSharedA11yFont(DeviceDpi, FontStyle.Bold); + + Font = _a11yFont; + + _lstPhrases.Font = _a11yFont; + + foreach (Control ctrl in _flpButtons.Controls) + { + if (ctrl is Button btn) + { + btn.Font = _a11yFont; + btn.AttachEyeTrackerFeedback( + baseDescription: btn.Tag?.ToString() ?? string.Empty, + regularFont: _a11yFont, + boldFont: _boldFont, + formCt: _cts?.Token ?? CancellationToken.None); + } + } + + _btnClose.Font = _a11yFont; + _btnClose.AttachEyeTrackerFeedback( + baseDescription: Strings.Phrase_A11y_Btn_Close_Desc, + regularFont: _a11yFont, + boldFont: _boldFont, + formCt: _cts?.Token ?? CancellationToken.None); + + _lblPhraseCount.Font = _a11yFont; + UpdatePhraseCountLabelMinimumWidth(); + } + + /// + /// 更新每個按鈕的最小尺寸(抗抖動 + WCAG 2.5.5 AAA 44×44) + /// + private void UpdateButtonMinimumSizes() + { + float scale = DeviceDpi / AppSettings.BaseDpi; + + UpdateSingleButtonMinimumSize(_btnAdd, scale); + UpdateSingleButtonMinimumSize(_btnEdit, scale); + UpdateSingleButtonMinimumSize(_btnDelete, scale); + UpdateSingleButtonMinimumSize(_btnMoveUp, scale); + UpdateSingleButtonMinimumSize(_btnMoveDown, scale); + UpdateSingleButtonMinimumSize(_btnClose, scale); + } + + /// + /// 更新單一按鈕的最小尺寸 + /// + /// 要更新最小尺寸的按鈕。 + /// 目前 DPI 相對於基準 DPI 的縮放比例。 + [MethodImpl(MethodImplOptions.AggressiveInlining)] + private void UpdateSingleButtonMinimumSize(Button btn, float scale) + { + try + { + if (btn.IsDisposed) + { + return; + } + Font boldFont = _boldFont ?? MainForm.GetSharedA11yFont(DeviceDpi, FontStyle.Bold); + + DialogLayoutHelper.UpdateButtonMinimumSize(btn, boldFont, scale, 44, 44, 24, 16); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] UpdateSingleButtonMinimumSize 失敗:{ex.Message}"); + } + } + + /// + /// 更新最小尺寸(依按鈕面板實際內容高度動態計算) + /// + /// 設為 強制重算,忽略 DPI 快取。 + private void UpdateMinimumSize(bool forceRecalculate = false) + { + float currentDpi = DeviceDpi; + + if (!DialogLayoutHelper.TryBeginDpiLayout(currentDpi, ref _lastAppliedDpi, forceRecalculate)) + { + return; + } + + float scale = currentDpi / AppSettings.BaseDpi; + + UpdatePhraseCountLabelMinimumWidth(); + + // 加上非客戶區(標題列+邊框)高度,MinimumSize 是外框尺寸。 + // OnHandleCreated 時 Height == ClientSize.Height(非客戶區尚未就緒), + // 故以 SystemInformation 估算作為最低保底值。 + int nonClientH = DialogLayoutHelper.GetEstimatedNonClientHeight(this); + + // 以主版面實際偏好尺寸作為基準,避免最後一顆按鈕在 row 0 被裁切。 + _tlpMain.PerformLayout(); + + Size preferred = _tlpMain.GetPreferredSize(Size.Empty); + + int desiredMinWidth = (int)(BaseDialogMinWidth * scale), + minW = Math.Max(desiredMinWidth, preferred.Width + Padding.Horizontal), + baseClientH = Math.Max( + (int)(300 * scale) - nonClientH, + preferred.Height + Padding.Vertical + (int)(8 * scale)), + minH = baseClientH + nonClientH; + + Rectangle workArea = Screen.GetWorkingArea(this); + + (int maxFitW, int maxFitH) = DialogLayoutHelper.GetMaxFitSize(workArea); + + minW = Math.Min(minW, maxFitW); + minH = Math.Min(minH, maxFitH); + + DialogLayoutHelper.ClampFormSize(this, minW, minH, maxFitW, maxFitH); + } + + /// + /// 更新視窗不透明度 + /// + private void UpdateOpacity() + { + if (SystemInformation.HighContrast) + { + Opacity = 1.0; + + return; + } + + Opacity = AppSettings.Current.WindowOpacity; + } + + /// + /// 智慧定位 + /// + private void ApplySmartPosition() + { + if (InputBoxLayoutManager.TryGetClampedLocation(this, out Point clampedLocation)) + { + Location = clampedLocation; + } + } + + /// + /// 強制重繪所有按鈕 + /// + private void InvalidateAllButtons() + { + _btnAdd.Invalidate(); + _btnEdit.Invalidate(); + _btnDelete.Invalidate(); + _btnMoveUp.Invalidate(); + _btnMoveDown.Invalidate(); + _btnClose.Invalidate(); + } + + /// + /// 系統偏好設定變更時同步更新尺寸、定位與按鈕視覺。 + /// + /// 事件來源。 + /// 系統偏好設定事件參數。 + private void SystemEvents_UserPreferenceChanged(object sender, UserPreferenceChangedEventArgs e) + { + try + { + if (e.Category is UserPreferenceCategory.Accessibility or + UserPreferenceCategory.Color or + UserPreferenceCategory.General) + { + this.SafeInvoke(() => + { + try + { + UpdateButtonMinimumSizes(); + UpdateMinimumSize(forceRecalculate: true); + ApplySmartPosition(); + InvalidateAllButtons(); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] SystemEvents 更新失敗:{ex.Message}"); + } + }); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] SystemEvents 處理失敗:{ex.Message}"); + } + } +} diff --git a/src/InputBox/Core/Controls/PhraseManagerDialog.Phrases.cs b/src/InputBox/Core/Controls/PhraseManagerDialog.Phrases.cs new file mode 100644 index 0000000..0978560 --- /dev/null +++ b/src/InputBox/Core/Controls/PhraseManagerDialog.Phrases.cs @@ -0,0 +1,408 @@ +using InputBox.Core.Configuration; +using InputBox.Core.Extensions; +using InputBox.Core.Feedback; +using InputBox.Core.Services; +using InputBox.Resources; +using System.Diagnostics; +using System.Media; + +namespace InputBox.Core.Controls; + +// 阻擋設計工具。 +partial class DesignerBlocker { }; + +/// +/// 片語管理對話框(片語清單操作分部)。 +/// 本分部檔案包含片語清單重新整理、新增、編輯、刪除、排序與插入等成員。 +/// +internal sealed partial class PhraseManagerDialog +{ + /// + /// 重新載入清單內容 + /// + private void RefreshList() + { + int selectedIndex = _lstPhrases.SelectedIndex; + + _lstPhrases.BeginUpdate(); + + try + { + _lstPhrases.Items.Clear(); + + foreach (PhraseService.PhraseEntry entry in _phraseService.Phrases) + { + _lstPhrases.Items.Add(entry.Name); + } + } + finally + { + _lstPhrases.EndUpdate(); + } + + // 還原選取位置。 + if (selectedIndex >= 0 && selectedIndex < _lstPhrases.Items.Count) + { + _lstPhrases.SelectedIndex = selectedIndex; + } + else if (_lstPhrases.Items.Count > 0) + { + _lstPhrases.SelectedIndex = Math.Min(selectedIndex, _lstPhrases.Items.Count - 1); + } + + UpdateButtonStates(); + UpdatePhraseCountHint(); + } + + /// + /// 更新按鈕啟用狀態 + /// + private void UpdateButtonStates() + { + bool hasSelection = _lstPhrases.SelectedIndex >= 0, + canAdd = _phraseService.Count < AppSettings.MaxPhraseCount; + + _btnAdd.Enabled = canAdd; + _btnEdit.Enabled = hasSelection; + _btnDelete.Enabled = hasSelection; + _btnMoveUp.Enabled = hasSelection && + _lstPhrases.SelectedIndex > 0; + _btnMoveDown.Enabled = hasSelection && + _lstPhrases.SelectedIndex < _lstPhrases.Items.Count - 1; + + UpdatePhraseCountHint(); + } + + /// + /// 更新片語數量提示(例如:片語數量:12/50)。 + /// + private void UpdatePhraseCountHint() + { + if (_lblPhraseCount == null || + _lblPhraseCount.IsDisposed) + { + return; + } + + UpdatePhraseCountLabelMinimumWidth(); + + string countText = $"{Strings.Phrase_A11y_List_Name}:{_phraseService.Count}/{AppSettings.MaxPhraseCount}"; + + _lblPhraseCount.Text = countText; + _lblPhraseCount.AccessibleName = countText; + _lblPhraseCount.AccessibleDescription = + $"{Strings.Phrase_A11y_List_Desc} {countText}"; + + if (SystemInformation.HighContrast) + { + _lblPhraseCount.ForeColor = Color.Empty; + + return; + } + + bool isNearLimit = _phraseService.Count >= AppSettings.MaxPhraseCount - 5; + + _lblPhraseCount.ForeColor = isNearLimit ? + Color.DarkOrange : + Color.Empty; + } + + /// + /// 預先鎖定片語數量提示標籤的寬度,避免數字變化時擠壓旁邊元件。 + /// + private void UpdatePhraseCountLabelMinimumWidth() + { + if (_lblPhraseCount == null || + _lblPhraseCount.IsDisposed) + { + return; + } + + string widestText = $"{Strings.Phrase_A11y_List_Name}:{AppSettings.MaxPhraseCount}/{AppSettings.MaxPhraseCount}"; + Size measured = TextRenderer.MeasureText( + widestText, + _lblPhraseCount.Font, + Size.Empty, + TextFormatFlags.NoPadding | TextFormatFlags.SingleLine); + + int horizontalPadding = (int)Math.Ceiling(12 * (DeviceDpi / AppSettings.BaseDpi)); + int width = Math.Max(_lblPhraseCount.MinimumSize.Width, measured.Width + horizontalPadding); + int height = Math.Max(_lblPhraseCount.MinimumSize.Height, measured.Height + 2); + + _lblPhraseCount.AutoSize = false; + _lblPhraseCount.MinimumSize = new Size(width, height); + _lblPhraseCount.Size = new Size(Math.Max(_lblPhraseCount.Width, width), height); + } + + /// + /// 新增片語 + /// + private void AddPhrase() + { + if (_phraseService.Count >= AppSettings.MaxPhraseCount) + { + FeedbackService.PlaySound(SystemSounds.Beep); + + AnnounceA11y(Strings.Phrase_A11y_Full); + + return; + } + + // 暫時解除本對話框的控制器事件,防止子對話框開啟期間事件同時觸發。 + UnsubscribeGamepadEvents(); + + try + { + using PhraseEditDialog dlg = new(string.Empty, string.Empty, _a11yFont) + { + GamepadController = _gamepadController + }; + dlg.StartPosition = FormStartPosition.Manual; + dlg.Location = new Point(Left + 20, Top + 20); + + if (dlg.ShowDialog(this) == DialogResult.OK && + !string.IsNullOrWhiteSpace(dlg.PhraseName) && + !string.IsNullOrWhiteSpace(dlg.PhraseContent)) + { + _phraseService.Add(dlg.PhraseName, dlg.PhraseContent); + + RefreshList(); + + _lstPhrases.SelectedIndex = _lstPhrases.Items.Count - 1; + + FeedbackService.PlaySound(SystemSounds.Asterisk); + + AnnounceA11y(AppSettings.Current.IsPrivacyMode ? + Strings.Phrase_A11y_Added_PrivacySafe : + string.Format(Strings.Phrase_A11y_Added, dlg.PhraseName)); + } + } + finally + { + // 子對話框關閉後重新訂閱控制器事件。 + SubscribeGamepadEvents(); + + // 防止 Owner/Child 失焦競態導致控制器殘留在 Pause 狀態。 + if (ActiveForm == this) + { + try + { + _gamepadController?.Resume(); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] AddPhrase Resume 失敗:{ex.Message}"); + } + } + } + } + + /// + /// 編輯選取的片語 + /// + private void EditSelectedPhrase() + { + int idx = _lstPhrases.SelectedIndex; + + if (idx < 0) + { + return; + } + + IReadOnlyList phrases = _phraseService.Phrases; + + if (idx >= phrases.Count) + { + return; + } + + PhraseService.PhraseEntry entry = phrases[idx]; + + // 暫時解除本對話框的控制器事件,防止子對話框開啟期間事件同時觸發。 + UnsubscribeGamepadEvents(); + + try + { + using PhraseEditDialog dlg = new(entry.Name, entry.Content, _a11yFont) + { + GamepadController = _gamepadController + }; + dlg.StartPosition = FormStartPosition.Manual; + dlg.Location = new Point(Left + 20, Top + 20); + + if (dlg.ShowDialog(this) == DialogResult.OK && + !string.IsNullOrWhiteSpace(dlg.PhraseName) && + !string.IsNullOrWhiteSpace(dlg.PhraseContent)) + { + _phraseService.Update(idx, dlg.PhraseName, dlg.PhraseContent); + + RefreshList(); + + FeedbackService.PlaySound(SystemSounds.Asterisk); + + AnnounceA11y(AppSettings.Current.IsPrivacyMode ? + Strings.Phrase_A11y_Updated_PrivacySafe : + string.Format(Strings.Phrase_A11y_Updated, dlg.PhraseName)); + } + } + finally + { + // 子對話框關閉後重新訂閱控制器事件。 + SubscribeGamepadEvents(); + + // 防止 Owner/Child 失焦競態導致控制器殘留在 Pause 狀態。 + if (ActiveForm == this) + { + try + { + _gamepadController?.Resume(); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] EditSelectedPhrase Resume 失敗:{ex.Message}"); + } + } + } + } + + /// + /// 刪除選取的片語 + /// + private void DeleteSelectedPhrase() + { + int idx = _lstPhrases.SelectedIndex; + + if (idx < 0) + { + return; + } + + IReadOnlyList phrases = _phraseService.Phrases; + + if (idx >= phrases.Count) + { + return; + } + + string name = phrases[idx].Name; + + // 暫時解除本對話框的控制器事件,防止子對話框開啟期間事件同時觸發。 + UnsubscribeGamepadEvents(); + + DialogResult confirmResult; + + try + { + // 確認刪除對話框(與應用程式其他訊息框使用相同風格) + confirmResult = GamepadMessageBox.Show( + this, + string.Format(Strings.Msg_ConfirmDeletePhrase, name), + Strings.Wrn_Title, + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning, + gamepad: _gamepadController); + } + finally + { + // 子對話框關閉後重新訂閱控制器事件。 + SubscribeGamepadEvents(); + + // 防止 Owner/Child 失焦競態導致控制器殘留在 Pause 狀態。 + if (ActiveForm == this) + { + try + { + _gamepadController?.Resume(); + } + catch (Exception ex) + { + Debug.WriteLine($"[片語] DeleteSelectedPhrase Resume 失敗:{ex.Message}"); + } + } + } + + if (confirmResult != DialogResult.Yes) + { + return; + } + + _phraseService.Remove(idx); + + RefreshList(); + + FeedbackService.PlaySound(SystemSounds.Asterisk); + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.ClearInput, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + + AnnounceA11y(AppSettings.Current.IsPrivacyMode ? + Strings.Phrase_A11y_Deleted_PrivacySafe : + string.Format(Strings.Phrase_A11y_Deleted, name)); + } + + /// + /// 移動選取的片語 + /// + /// -1 為上移,1 為下移 + private void MoveSelectedPhrase(int direction) + { + int idx = _lstPhrases.SelectedIndex; + + if (idx < 0) + { + return; + } + + bool success = direction < 0 ? + _phraseService.MoveUp(idx) : + _phraseService.MoveDown(idx); + + if (success) + { + RefreshList(); + + _lstPhrases.SelectedIndex = idx + direction; + + FeedbackService.VibrateAsync( + _gamepadController, + VibrationPatterns.CursorMove, + _cts?.Token ?? CancellationToken.None) + .SafeFireAndForget(); + + AnnounceA11y(string.Format(Strings.Phrase_A11y_Moved, _lstPhrases.SelectedIndex + 1)); + } + else + { + FeedbackService.PlaySound(SystemSounds.Beep); + } + } + + /// + /// 插入選取的片語(設定結果並關閉) + /// + private void InsertSelectedPhrase() + { + int idx = _lstPhrases.SelectedIndex; + + if (idx < 0) + { + return; + } + + IReadOnlyList phrases = _phraseService.Phrases; + + if (idx >= phrases.Count) + { + return; + } + + SelectedPhraseContent = phrases[idx].Content; + + DialogResult = DialogResult.OK; + + Close(); + } +} diff --git a/src/InputBox/Core/Controls/PhraseManagerDialog.cs b/src/InputBox/Core/Controls/PhraseManagerDialog.cs index fceb5d6..77350d8 100644 --- a/src/InputBox/Core/Controls/PhraseManagerDialog.cs +++ b/src/InputBox/Core/Controls/PhraseManagerDialog.cs @@ -1,15 +1,10 @@ using InputBox.Core.Configuration; using InputBox.Core.Extensions; -using InputBox.Core.Feedback; using InputBox.Core.Input; using InputBox.Core.Services; -using InputBox.Core.Utilities; using InputBox.Resources; using Microsoft.Win32; -using System.ComponentModel; using System.Diagnostics; -using System.Media; -using System.Runtime.CompilerServices; namespace InputBox.Core.Controls; @@ -20,7 +15,7 @@ partial class DesignerBlocker { }; /// 片語管理對話框 /// 提供片語的新增、編輯、刪除、排序功能,支援遊戲控制器操作、深色/淺色模式與無障礙。 /// -internal sealed class PhraseManagerDialog : Form +internal sealed partial class PhraseManagerDialog : Form { /// /// 片語管理視窗的基準最小寬度(96 DPI) @@ -122,29 +117,6 @@ internal sealed class PhraseManagerDialog : Form /// public string? SelectedPhraseContent { get; private set; } - /// - /// 設定控制器實作,並訂閱事件 - /// - [Browsable(false)] - [DesignerSerializationVisibility(DesignerSerializationVisibility.Hidden)] - public IGamepadController? GamepadController - { - get => _gamepadController; - set - { - if (ReferenceEquals(_gamepadController, value)) - { - return; - } - - UnsubscribeGamepadEvents(); - - _gamepadController = value; - - SubscribeGamepadEvents(); - } - } - /// /// 初始化片語管理對話框 /// @@ -523,76 +495,6 @@ await this.SafeInvokeAsync(() => }; } - protected override void OnHandleCreated(EventArgs e) - { - try - { - base.OnHandleCreated(e); - - ApplyFont(); - RefreshList(); - UpdateButtonStates(); - UpdateButtonMinimumSizes(); - UpdateMinimumSize(); - - this.SafeBeginInvoke(() => - { - try - { - UpdateOpacity(); - ApplySmartPosition(); - } - catch (Exception ex) - { - LoggerService.LogException(ex, "PhraseManagerDialog.OnHandleCreated 延遲邏輯失敗"); - - Debug.WriteLine($"[片語] OnHandleCreated 延遲邏輯失敗:{ex.Message}"); - } - }); - - SystemEvents.UserPreferenceChanged -= SystemEvents_UserPreferenceChanged; - SystemEvents.UserPreferenceChanged += SystemEvents_UserPreferenceChanged; - } - catch (Exception ex) - { - LoggerService.LogException(ex, "PhraseManagerDialog.OnHandleCreated 失敗"); - - Debug.WriteLine($"[片語] OnHandleCreated 失敗:{ex.Message}"); - } - } - - protected override void OnDpiChanged(DpiChangedEventArgs e) - { - try - { - base.OnDpiChanged(e); - - this.SafeInvoke(() => - { - try - { - ApplyFont(); - UpdateButtonMinimumSizes(); - UpdateMinimumSize(); - ApplySmartPosition(); - InvalidateAllButtons(); - } - catch (Exception ex) - { - LoggerService.LogException(ex, "PhraseManagerDialog.OnDpiChanged 延遲邏輯失敗"); - - Debug.WriteLine($"[片語] OnDpiChanged 失敗:{ex.Message}"); - } - }); - } - catch (Exception ex) - { - LoggerService.LogException(ex, "PhraseManagerDialog.OnDpiChanged 失敗"); - - Debug.WriteLine($"[片語] OnDpiChanged 失敗:{ex.Message}"); - } - } - protected override void OnShown(EventArgs e) { base.OnShown(e); @@ -613,13 +515,6 @@ protected override void OnShown(EventArgs e) } } - protected override void OnResizeEnd(EventArgs e) - { - base.OnResizeEnd(e); - - ApplySmartPosition(); - } - protected override bool ProcessCmdKey(ref Message msg, Keys keyData) { const int WM_KEYDOWN = 0x0100; @@ -672,1342 +567,4 @@ protected override void OnFormClosing(FormClosingEventArgs e) Debug.WriteLine($"[片語] OnFormClosing 失敗:{ex.Message}"); } } - - /// - /// Handle 銷毀時解除靜態系統事件訂閱,避免遺留參考。 - /// - /// 控制項事件參數。 - protected override void OnHandleDestroyed(EventArgs e) - { - try - { - SystemEvents.UserPreferenceChanged -= SystemEvents_UserPreferenceChanged; - } - finally - { - base.OnHandleDestroyed(e); - } - } - - #region 清單操作 - - /// - /// 重新載入清單內容 - /// - private void RefreshList() - { - int selectedIndex = _lstPhrases.SelectedIndex; - - _lstPhrases.BeginUpdate(); - - try - { - _lstPhrases.Items.Clear(); - - foreach (PhraseService.PhraseEntry entry in _phraseService.Phrases) - { - _lstPhrases.Items.Add(entry.Name); - } - } - finally - { - _lstPhrases.EndUpdate(); - } - - // 還原選取位置。 - if (selectedIndex >= 0 && selectedIndex < _lstPhrases.Items.Count) - { - _lstPhrases.SelectedIndex = selectedIndex; - } - else if (_lstPhrases.Items.Count > 0) - { - _lstPhrases.SelectedIndex = Math.Min(selectedIndex, _lstPhrases.Items.Count - 1); - } - - UpdateButtonStates(); - UpdatePhraseCountHint(); - } - - /// - /// 更新按鈕啟用狀態 - /// - private void UpdateButtonStates() - { - bool hasSelection = _lstPhrases.SelectedIndex >= 0, - canAdd = _phraseService.Count < AppSettings.MaxPhraseCount; - - _btnAdd.Enabled = canAdd; - _btnEdit.Enabled = hasSelection; - _btnDelete.Enabled = hasSelection; - _btnMoveUp.Enabled = hasSelection && - _lstPhrases.SelectedIndex > 0; - _btnMoveDown.Enabled = hasSelection && - _lstPhrases.SelectedIndex < _lstPhrases.Items.Count - 1; - - UpdatePhraseCountHint(); - } - - /// - /// 更新片語數量提示(例如:片語數量:12/50)。 - /// - private void UpdatePhraseCountHint() - { - if (_lblPhraseCount == null || - _lblPhraseCount.IsDisposed) - { - return; - } - - UpdatePhraseCountLabelMinimumWidth(); - - string countText = $"{Strings.Phrase_A11y_List_Name}:{_phraseService.Count}/{AppSettings.MaxPhraseCount}"; - - _lblPhraseCount.Text = countText; - _lblPhraseCount.AccessibleName = countText; - _lblPhraseCount.AccessibleDescription = - $"{Strings.Phrase_A11y_List_Desc} {countText}"; - - if (SystemInformation.HighContrast) - { - _lblPhraseCount.ForeColor = Color.Empty; - - return; - } - - bool isNearLimit = _phraseService.Count >= AppSettings.MaxPhraseCount - 5; - - _lblPhraseCount.ForeColor = isNearLimit ? - Color.DarkOrange : - Color.Empty; - } - - /// - /// 預先鎖定片語數量提示標籤的寬度,避免數字變化時擠壓旁邊元件。 - /// - private void UpdatePhraseCountLabelMinimumWidth() - { - if (_lblPhraseCount == null || - _lblPhraseCount.IsDisposed) - { - return; - } - - string widestText = $"{Strings.Phrase_A11y_List_Name}:{AppSettings.MaxPhraseCount}/{AppSettings.MaxPhraseCount}"; - Size measured = TextRenderer.MeasureText( - widestText, - _lblPhraseCount.Font, - Size.Empty, - TextFormatFlags.NoPadding | TextFormatFlags.SingleLine); - - int horizontalPadding = (int)Math.Ceiling(12 * (DeviceDpi / AppSettings.BaseDpi)); - int width = Math.Max(_lblPhraseCount.MinimumSize.Width, measured.Width + horizontalPadding); - int height = Math.Max(_lblPhraseCount.MinimumSize.Height, measured.Height + 2); - - _lblPhraseCount.AutoSize = false; - _lblPhraseCount.MinimumSize = new Size(width, height); - _lblPhraseCount.Size = new Size(Math.Max(_lblPhraseCount.Width, width), height); - } - - /// - /// 新增片語 - /// - private void AddPhrase() - { - if (_phraseService.Count >= AppSettings.MaxPhraseCount) - { - FeedbackService.PlaySound(SystemSounds.Beep); - - AnnounceA11y(Strings.Phrase_A11y_Full); - - return; - } - - // 暫時解除本對話框的控制器事件,防止子對話框開啟期間事件同時觸發。 - UnsubscribeGamepadEvents(); - - try - { - using PhraseEditDialog dlg = new(string.Empty, string.Empty, _a11yFont) - { - GamepadController = _gamepadController - }; - dlg.StartPosition = FormStartPosition.Manual; - dlg.Location = new Point(Left + 20, Top + 20); - - if (dlg.ShowDialog(this) == DialogResult.OK && - !string.IsNullOrWhiteSpace(dlg.PhraseName) && - !string.IsNullOrWhiteSpace(dlg.PhraseContent)) - { - _phraseService.Add(dlg.PhraseName, dlg.PhraseContent); - - RefreshList(); - - _lstPhrases.SelectedIndex = _lstPhrases.Items.Count - 1; - - FeedbackService.PlaySound(SystemSounds.Asterisk); - - AnnounceA11y(AppSettings.Current.IsPrivacyMode ? - Strings.Phrase_A11y_Added_PrivacySafe : - string.Format(Strings.Phrase_A11y_Added, dlg.PhraseName)); - } - } - finally - { - // 子對話框關閉後重新訂閱控制器事件。 - SubscribeGamepadEvents(); - - // 防止 Owner/Child 失焦競態導致控制器殘留在 Pause 狀態。 - if (ActiveForm == this) - { - try - { - _gamepadController?.Resume(); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] AddPhrase Resume 失敗:{ex.Message}"); - } - } - } - } - - /// - /// 編輯選取的片語 - /// - private void EditSelectedPhrase() - { - int idx = _lstPhrases.SelectedIndex; - - if (idx < 0) - { - return; - } - - IReadOnlyList phrases = _phraseService.Phrases; - - if (idx >= phrases.Count) - { - return; - } - - PhraseService.PhraseEntry entry = phrases[idx]; - - // 暫時解除本對話框的控制器事件,防止子對話框開啟期間事件同時觸發。 - UnsubscribeGamepadEvents(); - - try - { - using PhraseEditDialog dlg = new(entry.Name, entry.Content, _a11yFont) - { - GamepadController = _gamepadController - }; - dlg.StartPosition = FormStartPosition.Manual; - dlg.Location = new Point(Left + 20, Top + 20); - - if (dlg.ShowDialog(this) == DialogResult.OK && - !string.IsNullOrWhiteSpace(dlg.PhraseName) && - !string.IsNullOrWhiteSpace(dlg.PhraseContent)) - { - _phraseService.Update(idx, dlg.PhraseName, dlg.PhraseContent); - - RefreshList(); - - FeedbackService.PlaySound(SystemSounds.Asterisk); - - AnnounceA11y(AppSettings.Current.IsPrivacyMode ? - Strings.Phrase_A11y_Updated_PrivacySafe : - string.Format(Strings.Phrase_A11y_Updated, dlg.PhraseName)); - } - } - finally - { - // 子對話框關閉後重新訂閱控制器事件。 - SubscribeGamepadEvents(); - - // 防止 Owner/Child 失焦競態導致控制器殘留在 Pause 狀態。 - if (ActiveForm == this) - { - try - { - _gamepadController?.Resume(); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] EditSelectedPhrase Resume 失敗:{ex.Message}"); - } - } - } - } - - /// - /// 刪除選取的片語 - /// - private void DeleteSelectedPhrase() - { - int idx = _lstPhrases.SelectedIndex; - - if (idx < 0) - { - return; - } - - IReadOnlyList phrases = _phraseService.Phrases; - - if (idx >= phrases.Count) - { - return; - } - - string name = phrases[idx].Name; - - // 暫時解除本對話框的控制器事件,防止子對話框開啟期間事件同時觸發。 - UnsubscribeGamepadEvents(); - - DialogResult confirmResult; - - try - { - // 確認刪除對話框(與應用程式其他訊息框使用相同風格) - confirmResult = GamepadMessageBox.Show( - this, - string.Format(Strings.Msg_ConfirmDeletePhrase, name), - Strings.Wrn_Title, - MessageBoxButtons.YesNo, - MessageBoxIcon.Warning, - gamepad: _gamepadController); - } - finally - { - // 子對話框關閉後重新訂閱控制器事件。 - SubscribeGamepadEvents(); - - // 防止 Owner/Child 失焦競態導致控制器殘留在 Pause 狀態。 - if (ActiveForm == this) - { - try - { - _gamepadController?.Resume(); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] DeleteSelectedPhrase Resume 失敗:{ex.Message}"); - } - } - } - - if (confirmResult != DialogResult.Yes) - { - return; - } - - _phraseService.Remove(idx); - - RefreshList(); - - FeedbackService.PlaySound(SystemSounds.Asterisk); - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.ClearInput, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - - AnnounceA11y(AppSettings.Current.IsPrivacyMode ? - Strings.Phrase_A11y_Deleted_PrivacySafe : - string.Format(Strings.Phrase_A11y_Deleted, name)); - } - - /// - /// 移動選取的片語 - /// - /// -1 為上移,1 為下移 - private void MoveSelectedPhrase(int direction) - { - int idx = _lstPhrases.SelectedIndex; - - if (idx < 0) - { - return; - } - - bool success = direction < 0 ? - _phraseService.MoveUp(idx) : - _phraseService.MoveDown(idx); - - if (success) - { - RefreshList(); - - _lstPhrases.SelectedIndex = idx + direction; - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.CursorMove, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - - AnnounceA11y(string.Format(Strings.Phrase_A11y_Moved, _lstPhrases.SelectedIndex + 1)); - } - else - { - FeedbackService.PlaySound(SystemSounds.Beep); - } - } - - /// - /// 插入選取的片語(設定結果並關閉) - /// - private void InsertSelectedPhrase() - { - int idx = _lstPhrases.SelectedIndex; - - if (idx < 0) - { - return; - } - - IReadOnlyList phrases = _phraseService.Phrases; - - if (idx >= phrases.Count) - { - return; - } - - SelectedPhraseContent = phrases[idx].Content; - - DialogResult = DialogResult.OK; - - Close(); - } - - #endregion - - #region 控制器事件處理 - - /// - /// 判斷片語管理對話框目前是否應接手控制器輸入。 - /// - /// 若對話框可安全處理控制器操作則回傳 true。 - private bool CanHandleGamepadInput() - { - return Visible && - !IsDisposed && - (ActiveForm == this || - ContainsFocus || - _lstPhrases.Focused || - _lstPhrases.ContainsFocus || - IsButtonAreaFocused()); - } - - /// - /// 控制器向上輸入時在清單項目或按鈕焦點之間移動 - /// - private void HandleUp() => this.SafeInvoke(() => - { - try - { - if (!CanHandleGamepadInput()) - { - return; - } - - // 焦點在按鈕上時,D-Pad 上下切換焦點。 - if (IsButtonAreaFocused()) - { - CycleFocus(forward: false); - return; - } - - if (_lstPhrases.Items.Count > 0) - { - int newIdx = _lstPhrases.SelectedIndex <= 0 ? - _lstPhrases.Items.Count - 1 : - _lstPhrases.SelectedIndex - 1; - - _lstPhrases.SelectedIndex = newIdx; - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.CursorMove, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] HandleUp 失敗:{ex.Message}"); - } - }); - - /// - /// 控制器向下輸入時在清單項目或按鈕焦點之間移動 - /// - private void HandleDown() => this.SafeInvoke(() => - { - try - { - if (!CanHandleGamepadInput()) - { - return; - } - - // 焦點在按鈕上時,D-Pad 上下切換焦點。 - if (IsButtonAreaFocused()) - { - CycleFocus(forward: true); - - return; - } - - if (_lstPhrases.Items.Count > 0) - { - int newIdx = _lstPhrases.SelectedIndex >= _lstPhrases.Items.Count - 1 ? - 0 : - _lstPhrases.SelectedIndex + 1; - - _lstPhrases.SelectedIndex = newIdx; - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.CursorMove, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] HandleDown 失敗:{ex.Message}"); - } - }); - - /// - /// 控制器 A 鍵依目前焦點執行按鈕、插入片語或新增片語 - /// - private void HandleGamepadA() => this.SafeInvoke(() => - { - try - { - if (!CanHandleGamepadInput()) - { - return; - } - - // 如果焦點在按鈕上,執行該按鈕的動作。 - if (TryGetFocusedButton(out Button? focusedBtn) && - focusedBtn is { Enabled: true }) - { - focusedBtn.PerformClick(); - } - else if (_lstPhrases.Focused && - _lstPhrases.SelectedIndex >= 0) - { - InsertSelectedPhrase(); - } - else - { - AddPhrase(); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] HandleGamepadA 失敗:{ex.Message}"); - } - }); - - /// - /// 控制器取消動作時關閉片語管理對話框 - /// - private void HandleClose() => this.SafeInvoke(() => - { - try - { - if (!CanHandleGamepadInput()) - { - return; - } - - Close(); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] HandleClose 失敗:{ex.Message}"); - } - }); - - /// - /// 控制器刪除動作時移除目前選取的片語 - /// - private void HandleDelete() => this.SafeInvoke(() => - { - try - { - if (!CanHandleGamepadInput()) - { - return; - } - - DeleteSelectedPhrase(); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] HandleDelete 失敗:{ex.Message}"); - } - }); - - /// - /// 控制器新增動作時開啟片語建立流程 - /// - private void HandleAdd() => this.SafeInvoke(() => - { - try - { - if (GamescopeSurfaceRecovery.TryRecoverFromGamepadChord( - this, - RecreateHandle, - _gamepadController, - context: "PhraseManagerDialog Gamescope surface recovery 失敗")) - { - return; - } - - if (!CanHandleGamepadInput()) - { - return; - } - - AddPhrase(); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] HandleAdd 失敗:{ex.Message}"); - } - }); - - /// - /// LB/RB 捷徑切換左側片語清單中的上一筆或下一筆,讓使用者可快速瀏覽而不影響右側按鈕區焦點邏輯。 - /// - /// -1 代表上一筆,+1 代表下一筆。 - private void HandlePhraseShortcutStep(int delta) => this.SafeInvoke(() => - { - try - { - if (!CanHandleGamepadInput() || - _lstPhrases.Items.Count == 0) - { - return; - } - - int currentIndex = _lstPhrases.SelectedIndex < 0 ? - 0 : - _lstPhrases.SelectedIndex; - int targetIndex = Math.Clamp(currentIndex + delta, 0, _lstPhrases.Items.Count - 1); - - _lstPhrases.Focus(); - - if (targetIndex == _lstPhrases.SelectedIndex) - { - FeedbackService.PlaySound(SystemSounds.Beep); - - return; - } - - _lstPhrases.SelectedIndex = targetIndex; - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.CursorMove, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] HandlePhraseShortcutStep 失敗:{ex.Message}"); - } - }); - - /// - /// LT/RT 捷徑直接跳到左側片語清單的第一筆或最後一筆,加快大量片語時的巡覽效率。 - /// - /// true 表示跳到最後一筆;false 表示跳到第一筆。 - private void HandlePhraseShortcutBoundary(bool last) => this.SafeInvoke(() => - { - try - { - if (!CanHandleGamepadInput() || - _lstPhrases.Items.Count == 0) - { - return; - } - - int targetIndex = last ? - _lstPhrases.Items.Count - 1 : - 0; - - _lstPhrases.Focus(); - - if (targetIndex == _lstPhrases.SelectedIndex) - { - FeedbackService.PlaySound(SystemSounds.Beep); - - return; - } - - _lstPhrases.SelectedIndex = targetIndex; - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.CursorMove, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] HandlePhraseShortcutBoundary 失敗:{ex.Message}"); - } - }); - - /// - /// 控制器 LB 捷徑切換到上一個片語。 - /// - private void HandlePreviousPhraseShortcut() => HandlePhraseShortcutStep(-1); - - /// - /// 控制器 RB 捷徑切換到下一個片語。 - /// - private void HandleNextPhraseShortcut() => HandlePhraseShortcutStep(1); - - /// - /// 控制器 LT 捷徑直接跳到第一個片語。 - /// - private void HandleFirstPhraseShortcut() => HandlePhraseShortcutBoundary(last: false); - - /// - /// 控制器 RT 捷徑直接跳到最後一個片語。 - /// - private void HandleLastPhraseShortcut() => HandlePhraseShortcutBoundary(last: true); - - /// - /// 控制器左向輸入時回到清單或將片語上移 - /// - private void HandleMoveUp() => this.SafeInvoke(() => - { - try - { - if (!CanHandleGamepadInput()) - { - return; - } - - // 左方向鍵:若目前在右側按鈕區,回到清單。 - if (IsButtonAreaFocused()) - { - _lstPhrases.Focus(); - - if (_lstPhrases.SelectedIndex >= 0) - { - IReadOnlyList phrases = _phraseService.Phrases; - - if (_lstPhrases.SelectedIndex < phrases.Count) - { - AnnounceA11y( - AppSettings.Current.IsPrivacyMode ? - Strings.Phrase_A11y_Selected_PrivacySafe : - string.Format(Strings.Phrase_A11y_Selected, phrases[_lstPhrases.SelectedIndex].Name), - interrupt: true); - } - } - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.CursorMove, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - - return; - } - - MoveSelectedPhrase(-1); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] HandleMoveUp 失敗:{ex.Message}"); - } - }); - - /// - /// 控制器右向輸入時進入按鈕區或將片語下移 - /// - private void HandleMoveDown() => this.SafeInvoke(() => - { - try - { - if (!CanHandleGamepadInput()) - { - return; - } - - // 右方向鍵:由清單進入右側按鈕區;若已在按鈕區則向後循環。 - if (_lstPhrases.Focused || - _lstPhrases.ContainsFocus) - { - FocusFirstActionButtonOrClose(); - - return; - } - - if (IsButtonAreaFocused()) - { - CycleFocus(forward: true); - - return; - } - - MoveSelectedPhrase(1); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] HandleMoveDown 失敗:{ex.Message}"); - } - }); - - /// - /// 在清單與按鈕之間循環焦點 - /// - /// 是否向前循環焦點 - private void CycleFocus(bool forward) - { - // 建立焦點順序:清單 → 各按鈕(依面板順序)→ 關閉按鈕。 - List focusOrder = [_lstPhrases]; - - foreach (Control ctrl in _flpButtons.Controls) - { - if (ctrl is Button btn && - btn.Enabled && - btn.Visible) - { - focusOrder.Add(btn); - } - } - - if (_btnClose.Enabled && _btnClose.Visible) - { - focusOrder.Add(_btnClose); - } - - if (focusOrder.Count == 0) - { - return; - } - - // 找到目前焦點在列表中的位置。 - int currentIdx = -1; - - for (int i = 0; i < focusOrder.Count; i++) - { - if (focusOrder[i].Focused || - focusOrder[i].ContainsFocus) - { - currentIdx = i; - - break; - } - } - - int nextIdx; - - if (currentIdx < 0) - { - nextIdx = forward ? - 0 : - focusOrder.Count - 1; - } - else - { - nextIdx = forward ? - (currentIdx + 1) % focusOrder.Count : - (currentIdx - 1 + focusOrder.Count) % focusOrder.Count; - } - - focusOrder[nextIdx].Focus(); - - // 播報焦點目標。 - string? name = focusOrder[nextIdx].AccessibleName ?? focusOrder[nextIdx].Text; - - if (!string.IsNullOrEmpty(name)) - { - AnnounceA11y(name, interrupt: true); - } - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.CursorMove, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - - /// - /// 嘗試找出目前在按鈕區取得焦點的按鈕 - /// - /// 輸出的焦點按鈕。 - /// 若找到焦點按鈕則回傳 true。 - private bool TryGetFocusedButton(out Button? focusedBtn) - { - if (_btnClose.Focused || - _btnClose.ContainsFocus) - { - focusedBtn = _btnClose; - - return true; - } - - foreach (Control ctrl in _flpButtons.Controls) - { - if (ctrl is Button btn && - (btn.Focused || btn.ContainsFocus)) - { - focusedBtn = btn; - - return true; - } - } - - focusedBtn = null; - - return false; - } - - /// - /// 判斷目前焦點是否位於按鈕區 - /// - /// 若按鈕區有焦點則回傳 true。 - private bool IsButtonAreaFocused() => TryGetFocusedButton(out _); - - /// - /// 將焦點移至第一個可用動作按鈕,否則移至關閉按鈕。 - /// - private void FocusFirstActionButtonOrClose() - { - foreach (Control ctrl in _flpButtons.Controls) - { - if (ctrl is Button btn && - btn.Enabled && - btn.Visible) - { - btn.Focus(); - - AnnounceA11y(btn.AccessibleName ?? btn.Text, interrupt: true); - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.CursorMove, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - - return; - } - } - - if (_btnClose.Enabled && - _btnClose.Visible) - { - _btnClose.Focus(); - - AnnounceA11y(_btnClose.AccessibleName ?? _btnClose.Text, interrupt: true); - - FeedbackService.VibrateAsync( - _gamepadController, - VibrationPatterns.CursorMove, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - } - - /// - /// 控制器連線狀態變更時更新恢復狀態並廣播無障礙訊息。 - /// - /// 連線成功為 ,斷線為 。 - private void HandleGamepadConnectionChanged(bool connected) - { - try - { - if (connected) - { - _gamepadController?.Resume(); - } - - AnnounceA11y(connected ? - string.Format(Strings.A11y_Gamepad_Connected, _gamepadController?.DeviceName) : - string.Format(Strings.A11y_Gamepad_Disconnected, _gamepadController?.DeviceName)); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] 控制器連線變更處理失敗:{ex.Message}"); - } - } - - /// - /// 訂閱片語管理對話框使用的控制器事件 - /// - private void SubscribeGamepadEvents() - { - try - { - if (_gamepadController != null) - { - GamepadFaceButtonProfile profile = GamepadFaceButtonProfile.GetActiveProfile(); - - _gamepadController.UpPressed += HandleUp; - _gamepadController.UpRepeat += HandleUp; - _gamepadController.DownPressed += HandleDown; - _gamepadController.DownRepeat += HandleDown; - _gamepadController.APressed += profile.ConfirmOnSouth ? HandleGamepadA : HandleClose; - _gamepadController.StartPressed += HandleGamepadA; - _gamepadController.BPressed += profile.ConfirmOnSouth ? HandleClose : HandleGamepadA; - _gamepadController.BackPressed += HandleClose; - _gamepadController.XPressed += HandleDelete; - _gamepadController.YPressed += HandleAdd; - _gamepadController.LeftPressed += HandleMoveUp; - _gamepadController.LeftRepeat += HandleMoveUp; - _gamepadController.RightPressed += HandleMoveDown; - _gamepadController.RightRepeat += HandleMoveDown; - _gamepadController.LeftShoulderPressed += HandlePreviousPhraseShortcut; - _gamepadController.LeftShoulderRepeat += HandlePreviousPhraseShortcut; - _gamepadController.RightShoulderPressed += HandleNextPhraseShortcut; - _gamepadController.RightShoulderRepeat += HandleNextPhraseShortcut; - _gamepadController.LeftTriggerPressed += HandleFirstPhraseShortcut; - _gamepadController.RightTriggerPressed += HandleLastPhraseShortcut; - _gamepadController.ConnectionChanged += HandleGamepadConnectionChanged; - } - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] SubscribeGamepadEvents 失敗:{ex.Message}"); - } - } - - /// - /// 解除片語管理對話框使用的控制器事件 - /// - private void UnsubscribeGamepadEvents() - { - try - { - if (_gamepadController != null) - { - _gamepadController.UpPressed -= HandleUp; - _gamepadController.UpRepeat -= HandleUp; - _gamepadController.DownPressed -= HandleDown; - _gamepadController.DownRepeat -= HandleDown; - _gamepadController.APressed -= HandleGamepadA; - _gamepadController.APressed -= HandleClose; - _gamepadController.StartPressed -= HandleGamepadA; - _gamepadController.BPressed -= HandleGamepadA; - _gamepadController.BPressed -= HandleClose; - _gamepadController.BackPressed -= HandleClose; - _gamepadController.XPressed -= HandleDelete; - _gamepadController.YPressed -= HandleAdd; - _gamepadController.LeftPressed -= HandleMoveUp; - _gamepadController.LeftRepeat -= HandleMoveUp; - _gamepadController.RightPressed -= HandleMoveDown; - _gamepadController.RightRepeat -= HandleMoveDown; - _gamepadController.LeftShoulderPressed -= HandlePreviousPhraseShortcut; - _gamepadController.LeftShoulderRepeat -= HandlePreviousPhraseShortcut; - _gamepadController.RightShoulderPressed -= HandleNextPhraseShortcut; - _gamepadController.RightShoulderRepeat -= HandleNextPhraseShortcut; - _gamepadController.LeftTriggerPressed -= HandleFirstPhraseShortcut; - _gamepadController.RightTriggerPressed -= HandleLastPhraseShortcut; - _gamepadController.ConnectionChanged -= HandleGamepadConnectionChanged; - } - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] UnsubscribeGamepadEvents 失敗:{ex.Message}"); - } - } - - #endregion - - #region 視覺與佈局 - - - /// - /// 建立動作按鈕 - /// - /// 按鈕文字 - /// 輔助功能描述 - /// 快捷鍵字元 - /// 已設定樣式、快捷鍵與無障礙屬性的動作按鈕執行個體。 - private static Button CreateActionButton( - string text, - string a11yDesc, - char mnemonic) - { - Button btn = new() - { - Text = ControlExtensions.GetMnemonicText(text, mnemonic), - AutoSize = true, - FlatStyle = FlatStyle.Flat, - AccessibleName = text, - AccessibleDescription = a11yDesc, - AccessibleRole = AccessibleRole.PushButton, - BackColor = Color.Empty, - ForeColor = Color.Empty, - Margin = new Padding(2, 2, 2, 2), - Anchor = AnchorStyles.Left | AnchorStyles.Right - }; - btn.FlatAppearance.BorderSize = 0; - - // 暫存 base description 到 Tag 供後續 AttachEyeTrackerFeedback 使用。 - btn.Tag = a11yDesc; - - return btn; - } - - /// - /// 套用字型(Regular + Bold)並掛載眼動儀擴充 - /// - private void ApplyFont() - { - _a11yFont = MainForm.GetSharedA11yFont(DeviceDpi, FontStyle.Regular); - _boldFont = MainForm.GetSharedA11yFont(DeviceDpi, FontStyle.Bold); - - Font = _a11yFont; - - _lstPhrases.Font = _a11yFont; - - foreach (Control ctrl in _flpButtons.Controls) - { - if (ctrl is Button btn) - { - btn.Font = _a11yFont; - btn.AttachEyeTrackerFeedback( - baseDescription: btn.Tag?.ToString() ?? string.Empty, - regularFont: _a11yFont, - boldFont: _boldFont, - formCt: _cts?.Token ?? CancellationToken.None); - } - } - - _btnClose.Font = _a11yFont; - _btnClose.AttachEyeTrackerFeedback( - baseDescription: Strings.Phrase_A11y_Btn_Close_Desc, - regularFont: _a11yFont, - boldFont: _boldFont, - formCt: _cts?.Token ?? CancellationToken.None); - - _lblPhraseCount.Font = _a11yFont; - UpdatePhraseCountLabelMinimumWidth(); - } - - /// - /// 更新每個按鈕的最小尺寸(抗抖動 + WCAG 2.5.5 AAA 44×44) - /// - private void UpdateButtonMinimumSizes() - { - float scale = DeviceDpi / AppSettings.BaseDpi; - - UpdateSingleButtonMinimumSize(_btnAdd, scale); - UpdateSingleButtonMinimumSize(_btnEdit, scale); - UpdateSingleButtonMinimumSize(_btnDelete, scale); - UpdateSingleButtonMinimumSize(_btnMoveUp, scale); - UpdateSingleButtonMinimumSize(_btnMoveDown, scale); - UpdateSingleButtonMinimumSize(_btnClose, scale); - } - - /// - /// 更新單一按鈕的最小尺寸 - /// - /// 要更新最小尺寸的按鈕。 - /// 目前 DPI 相對於基準 DPI 的縮放比例。 - [MethodImpl(MethodImplOptions.AggressiveInlining)] - private void UpdateSingleButtonMinimumSize(Button btn, float scale) - { - try - { - if (btn.IsDisposed) - { - return; - } - Font boldFont = _boldFont ?? MainForm.GetSharedA11yFont(DeviceDpi, FontStyle.Bold); - - DialogLayoutHelper.UpdateButtonMinimumSize(btn, boldFont, scale, 44, 44, 24, 16); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] UpdateSingleButtonMinimumSize 失敗:{ex.Message}"); - } - } - - /// - /// 更新最小尺寸(依按鈕面板實際內容高度動態計算) - /// - /// 設為 強制重算,忽略 DPI 快取。 - private void UpdateMinimumSize(bool forceRecalculate = false) - { - float currentDpi = DeviceDpi; - - if (!DialogLayoutHelper.TryBeginDpiLayout(currentDpi, ref _lastAppliedDpi, forceRecalculate)) - { - return; - } - - float scale = currentDpi / AppSettings.BaseDpi; - - UpdatePhraseCountLabelMinimumWidth(); - - // 加上非客戶區(標題列+邊框)高度,MinimumSize 是外框尺寸。 - // OnHandleCreated 時 Height == ClientSize.Height(非客戶區尚未就緒), - // 故以 SystemInformation 估算作為最低保底值。 - int nonClientH = DialogLayoutHelper.GetEstimatedNonClientHeight(this); - - // 以主版面實際偏好尺寸作為基準,避免最後一顆按鈕在 row 0 被裁切。 - _tlpMain.PerformLayout(); - - Size preferred = _tlpMain.GetPreferredSize(Size.Empty); - - int desiredMinWidth = (int)(BaseDialogMinWidth * scale), - minW = Math.Max(desiredMinWidth, preferred.Width + Padding.Horizontal), - baseClientH = Math.Max( - (int)(300 * scale) - nonClientH, - preferred.Height + Padding.Vertical + (int)(8 * scale)), - minH = baseClientH + nonClientH; - - Rectangle workArea = Screen.GetWorkingArea(this); - - (int maxFitW, int maxFitH) = DialogLayoutHelper.GetMaxFitSize(workArea); - - minW = Math.Min(minW, maxFitW); - minH = Math.Min(minH, maxFitH); - - DialogLayoutHelper.ClampFormSize(this, minW, minH, maxFitW, maxFitH); - } - - /// - /// 更新視窗不透明度 - /// - private void UpdateOpacity() - { - if (SystemInformation.HighContrast) - { - Opacity = 1.0; - - return; - } - - Opacity = AppSettings.Current.WindowOpacity; - } - - /// - /// 智慧定位 - /// - private void ApplySmartPosition() - { - if (InputBoxLayoutManager.TryGetClampedLocation(this, out Point clampedLocation)) - { - Location = clampedLocation; - } - } - - /// - /// 強制重繪所有按鈕 - /// - private void InvalidateAllButtons() - { - _btnAdd.Invalidate(); - _btnEdit.Invalidate(); - _btnDelete.Invalidate(); - _btnMoveUp.Invalidate(); - _btnMoveDown.Invalidate(); - _btnClose.Invalidate(); - } - - /// - /// 系統偏好設定變更時同步更新尺寸、定位與按鈕視覺。 - /// - /// 事件來源。 - /// 系統偏好設定事件參數。 - private void SystemEvents_UserPreferenceChanged(object sender, UserPreferenceChangedEventArgs e) - { - try - { - if (e.Category is UserPreferenceCategory.Accessibility or - UserPreferenceCategory.Color or - UserPreferenceCategory.General) - { - this.SafeInvoke(() => - { - try - { - UpdateButtonMinimumSizes(); - UpdateMinimumSize(forceRecalculate: true); - ApplySmartPosition(); - InvalidateAllButtons(); - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] SystemEvents 更新失敗:{ex.Message}"); - } - }); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] SystemEvents 處理失敗:{ex.Message}"); - } - } - - #endregion - - #region A11y - - /// - /// 內部 A11y 廣播 - /// - /// 要廣播的無障礙訊息文字。 - /// 設為 可中斷目前朗讀(需設定允許中斷)。 - private void AnnounceA11y(string message, bool interrupt = false) - { - if (IsDisposed || - string.IsNullOrEmpty(message)) - { - return; - } - - if (Owner is MainForm mainForm) - { - mainForm.AnnounceA11y(message, interrupt); - } - else - { - long currentId = Interlocked.Increment(ref _a11yDebounceId); - - Task.Run(async () => - { - try - { - await Task.Delay(AppSettings.AudioDuckingDelayMs, _cts?.Token ?? CancellationToken.None); - - if (Interlocked.Read(ref _a11yDebounceId) == currentId && - !IsDisposed && - IsHandleCreated) - { - await this.SafeInvokeAsync(() => - _announcer.Announce(message, interrupt && AppSettings.Current.A11yInterruptEnabled)); - } - } - catch (OperationCanceledException) - { - - } - catch (Exception ex) - { - Debug.WriteLine($"[片語] A11y 廣播失敗:{ex.Message}"); - } - }, - _cts?.Token ?? CancellationToken.None) - .SafeFireAndForget(); - } - } - - #endregion -} \ No newline at end of file +} diff --git a/src/InputBox/Core/Extensions/ControlExtensions.cs b/src/InputBox/Core/Extensions/ControlExtensions.cs index 46ce3d7..374153e 100644 --- a/src/InputBox/Core/Extensions/ControlExtensions.cs +++ b/src/InputBox/Core/Extensions/ControlExtensions.cs @@ -1,4 +1,5 @@ using InputBox.Core.Configuration; +using InputBox.Core.Interop; using InputBox.Core.Services; using System.Diagnostics; using System.Drawing.Drawing2D; @@ -739,6 +740,85 @@ public static void ResetThemeRecursive(this Control parent) UpdateRecursive(parent, Color.Empty, Color.Empty); } + /// + /// 在不改變目前選取範圍的前提下,計算從指定游標位置執行單字跳轉後的目標位置 + /// + /// 目標文字方塊。 + /// 起算的游標位置(字元索引)。 + /// 是否向右跳轉。 + /// 單字跳轉後的游標位置;文字方塊不可用時回傳原位置。 + public static int GetWordJumpTarget(this TextBox textBox, int caret, bool forward) + { + if (textBox == null || + textBox.IsDisposed) + { + return caret; + } + + int originalStart = textBox.SelectionStart; + int originalLength = textBox.SelectionLength; + + try + { + textBox.SelectionStart = Math.Clamp(caret, 0, textBox.TextLength); + textBox.SelectionLength = 0; + textBox.WordJump(forward); + + return textBox.SelectionStart; + } + finally + { + textBox.SelectionStart = originalStart; + textBox.SelectionLength = originalLength; + } + } + + /// + /// 解析延伸選取時的錨點與目前活動邊緣(游標所在端) + /// + /// + /// 目前沒有選取範圍,或選取範圍兩端都不等於既有錨點(例如使用者以滑鼠或鍵盤改變過選取)時, + /// 以目前選取起點重新作為錨點。WinForms 的 永遠是較小的索引, + /// 因此起點等於錨點時活動邊緣在右側,否則在左側。 + /// + /// 目標文字方塊。 + /// 呼叫端保存的既有錨點;尚未建立時為 null。 + /// 本次應使用的錨點與活動邊緣。 + public static (int Anchor, int ActiveEdge) ResolveSelectionAnchor(this TextBox textBox, int? currentAnchor) + { + int selectionStart = textBox.SelectionStart; + int selectionLength = textBox.SelectionLength; + + int anchor = selectionLength == 0 || + currentAnchor == null || + (selectionStart != currentAnchor.Value && + selectionStart + selectionLength != currentAnchor.Value) ? + selectionStart : + currentAnchor.Value; + + int activeEdge = selectionStart == anchor ? + anchor + selectionLength : + selectionStart; + + return (anchor, activeEdge); + } + + /// + /// 以錨點與活動邊緣設定選取範圍(支援反向選取),並捲動讓活動邊緣保持可見 + /// + /// + /// 透過 EM_SETSEL 設定,wParam 為錨點、lParam 為活動邊緣,讓視覺游標跟隨活動邊緣並支援反向縮減; + /// 捲動可避免選取延伸到可視範圍外時,TextBox 在邊界繪製選取底線殘影。 + /// + /// 目標文字方塊。 + /// 選取錨點。 + /// 選取活動邊緣。 + public static void SetSelectionWithActiveEdge(this TextBox textBox, int anchor, int activeEdge) + { + User32.SendMessage(textBox.Handle, (uint)User32.WindowMessage.EM_SETSEL, anchor, activeEdge); + textBox.ScrollToCaret(); + } + /// /// 執行單字跳轉邏輯(智慧偵測空白、標點符號、字元類型轉換,支援全形與 IME 情境) /// diff --git a/src/InputBox/Core/Feedback/FlashAlertAnimator.cs b/src/InputBox/Core/Feedback/FlashAlertAnimator.cs new file mode 100644 index 0000000..a77d9b9 --- /dev/null +++ b/src/InputBox/Core/Feedback/FlashAlertAnimator.cs @@ -0,0 +1,173 @@ +using InputBox.Core.Configuration; +using InputBox.Core.Extensions; +using System.Diagnostics; + +namespace InputBox.Core.Feedback; + +/// +/// 輸入邊界與驗證失敗時共用的視覺閃爍警示:警示色、每一幀的前景/背景配色,以及光敏安全的動畫節奏 +/// +/// +/// 主視窗、數值輸入與片語編輯三處共用此實作,確保 1Hz 頻率上限、動畫偏好退回與 WCAG 文字對比規則只維護一份。 +/// 套用目標控制項、狀態旗標與結束後的視覺還原仍由各呼叫端負責。 +/// +internal static class FlashAlertAnimator +{ + /// + /// 關閉動畫時以單次長脈衝維持警示的時間(毫秒),讓低視能使用者有足夠時間感知狀態。 + /// + private const int StaticPulseDurationMs = 800; + + /// + /// WCAG 相對亮度切換閾值:背景亮度高於此值時改用黑色文字,否則使用白色文字。 + /// + /// + /// 取黑白文字對比相等的交叉點(L≈0.1791),避免以 YUV≈128 近似在切換帶(intensity≈0.75) + /// 讓文字對比跌破 AA;精確切換後全程 ≥4.64:1,14f 粗體大型文字全程 ≥4.5:1。 + /// + internal const float ForegroundLuminanceThreshold = 0.1791f; + + /// + /// 取得警示色 + /// + /// + /// 選色對齊反轉後的控制項背景:淺色模式(黑底)使用 DarkOrange(8.3:1);深色模式(白底)使用 Firebrick(5.8:1); + /// 高對比模式一律使用系統醒目提示色。 + /// + /// 目標控制項目前是否為深色模式。 + /// 系統是否啟用高對比模式。 + /// 警示色。 + public static Color GetAlertColor(bool isDark, bool highContrast) + { + return highContrast ? + SystemColors.Highlight : + (isDark ? Color.Firebrick : Color.DarkOrange); + } + + /// + /// 依閃爍強度計算單一幀的背景與前景色 + /// + /// + /// 一般模式由純淨底色(深色用白、淺色用黑)線性插值至警示色,避免與高飽和焦點色插值產生髒濁色; + /// 前景色依 sRGB 線性化後的相對亮度切換黑白。高對比模式不插值,強度過半即切換為警示配色。 + /// + /// 閃爍強度(0 到 1)。 + /// 目標控制項目前是否為深色模式。 + /// 由 取得的警示色。 + /// 系統是否啟用高對比模式。 + /// 本幀的背景色與前景色。 + public static (Color Back, Color Fore) ComputeFrameColors( + float intensity, + bool isDark, + Color alertColor, + bool highContrast) + { + if (highContrast) + { + bool isAlert = intensity > 0.5f; + + return isAlert ? + (alertColor, SystemColors.HighlightText) : + (SystemColors.Window, SystemColors.WindowText); + } + + Color pureBase = isDark ? + Color.White : + Color.Black; + + int r = (int)(pureBase.R + (alertColor.R - pureBase.R) * intensity), + g = (int)(pureBase.G + (alertColor.G - pureBase.G) * intensity), + b = (int)(pureBase.B + (alertColor.B - pureBase.B) * intensity); + + Color back = Color.FromArgb(255, r, g, b); + + Color fore = GetRelativeLuminance(back) > ForegroundLuminanceThreshold ? + Color.Black : + Color.White; + + return (back, fore); + } + + /// + /// 播放閃爍動畫:預設為 週期的單次正弦脈衝; + /// 系統關閉動畫效果或使用者停用動畫警示時,改為單次長脈衝 + /// + /// 用於在 UI 執行緒套用每一幀的控制項。 + /// 套用指定強度(0 到 1)的委派,會在 UI 執行緒上呼叫。 + /// 取消權杖;取消時擲出 。 + /// 動畫完成或取消時結束的工作。 + public static async Task RunAsync( + Control owner, + Action applyFrame, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(owner); + ArgumentNullException.ThrowIfNull(applyFrame); + + // 嚴格遵守光敏性癲癇防護與使用者偏好: + // 系統層級關閉動畫效果(UIEffectsEnabled 為 false)或停用動畫警示時,不進行循環閃爍。 + if (!SystemInformation.UIEffectsEnabled || + !AppSettings.Current.EnableAnimatedVisualAlerts) + { + await owner.SafeInvokeAsync(() => applyFrame(1.0f)); + + await Task.Delay(StaticPulseDurationMs, cancellationToken); + + return; + } + + int totalDuration = AppSettings.PhotoSafeFrequencyMs; + + using PeriodicTimer timer = new(TimeSpan.FromMilliseconds(AppSettings.TargetFrameTimeMs)); + + long startTime = Stopwatch.GetTimestamp(); + + while (await timer.WaitForNextTickAsync(cancellationToken)) + { + double elapsedMs = Stopwatch.GetElapsedTime(startTime).TotalMilliseconds; + + if (elapsedMs >= totalDuration) + { + break; + } + + float intensity = ComputeIntensity(elapsedMs, totalDuration); + + await owner.SafeInvokeAsync(() => applyFrame(intensity)); + } + } + + /// + /// 依經過時間計算正弦脈衝強度:從 0 開始、在週期中點達到 1,再回到 0 + /// + /// 動畫開始後的經過時間(毫秒)。 + /// 脈衝週期(毫秒)。 + /// 閃爍強度(0 到 1)。 + internal static float ComputeIntensity(double elapsedMs, double periodMs) + { + double angle = elapsedMs / periodMs * 2.0 * Math.PI - (Math.PI / 2.0); + + return (float)((Math.Sin(angle) + 1.0) / 2.0); + } + + /// + /// 計算色彩的 WCAG 相對亮度 + /// + /// 要計算的色彩。 + /// 相對亮度(0 到 1)。 + internal static float GetRelativeLuminance(Color color) + { + return 0.2126f * Linearize(color.R) + + 0.7152f * Linearize(color.G) + + 0.0722f * Linearize(color.B); + + static float Linearize(int channel) + { + float f = channel / 255f; + + return f <= 0.04045f ? + f / 12.92f : + MathF.Pow((f + 0.055f) / 1.055f, 2.4f); + } + } +} diff --git a/src/InputBox/Core/Input/GameInputGamepadController.cs b/src/InputBox/Core/Input/GameInputGamepadController.cs index 827b5e6..096c82a 100644 --- a/src/InputBox/Core/Input/GameInputGamepadController.cs +++ b/src/InputBox/Core/Input/GameInputGamepadController.cs @@ -32,12 +32,12 @@ internal sealed partial class GameInputGamepadController : IGamepadController private readonly GamepadRepeatSettings _repeatSettings; /// - /// 目前動態計算的連發間隔幀數(加入生理抖動) + /// 目前生效的連發間隔幀數(首次為初始延遲,之後為重複間隔) /// private int _currentRepeatInterval; /// - /// 目前動態計算的右搖桿連發間隔幀數(加入生理抖動) + /// 目前生效的右搖桿連發間隔幀數(首次為初始延遲,之後為重複間隔) /// private int _currentRSRepeatInterval; @@ -336,56 +336,9 @@ internal sealed partial class GameInputGamepadController : IGamepadController private const int MechanismIdleResetFrames = 120; #endif - // ── 自適應 EMA 係數(Adaptive Exponential Moving Average)──────────────── - // 設計原則:每個軸保有獨立的「基礎值」與「最大值」。 - // • 當估計誤差(rawValue − currentBias)落在 BiasAdaptiveErrorRange 以內時, - // 學習率從 Base 線性插值至 Max,誤差越大學習越快(快速收斂)。 - // • 當誤差接近 0 時,退回 Base(保守維持),避免把有效輸入誤學成硬體偏移。 - // • 不同控制器硬體偏移量不同;此機制讓程式自動適應,無需手動調整。 - - /// - /// 左搖桿 X 軸:偏移估計的最低保守學習率(誤差接近 0 時使用)。 - /// - private const float LeftStickBiasXBaseSmoothing = 0.03f; - - /// - /// 左搖桿 X 軸:偏移估計的最高學習率(誤差達到 BiasAdaptiveErrorRange 時使用)。 - /// Log 顯示 biasLx 在 D-Pad 操作期間漂移幅度大,Max 值不宜過高以防誤學方向輸入。 - /// - private const float LeftStickBiasXMaxSmoothing = 0.15f; - - /// - /// 左搖桿 Y 軸:偏移估計的最低保守學習率。 - /// - private const float LeftStickBiasYBaseSmoothing = 0.03f; - - /// - /// 左搖桿 Y 軸:偏移估計的最高學習率。 - /// Log 顯示 biasLy 恆在 ±0.009 以內,目標穩定,可略低於 X 軸 Max。 - /// - private const float LeftStickBiasYMaxSmoothing = 0.12f; - - /// - /// 右搖桿 X 軸:偏移估計的最低保守學習率。 - /// - private const float RightStickBiasBaseSmoothing = 0.05f; - - /// - /// 右搖桿 X 軸:偏移估計的最高學習率。 - /// 右搖桿無 D-Pad 閘門,低 Max 可防止快速劃過中立區時累積偏移; - /// Warm-up 50 次後收斂率 ≈ 97.2%,起始收斂不受影響。 - /// - private const float RightStickBiasMaxSmoothing = 0.07f; - - /// - /// 右搖桿 Y 軸:偏移估計的最低保守學習率。 - /// - private const float RightStickBiasYBaseSmoothing = 0.05f; - - /// - /// 右搖桿 Y 軸:偏移估計的最高學習率。與 RX 相同理由,低 Max 防止壓力測試時逘速掟移。 - /// - private const float RightStickBiasYMaxSmoothing = 0.07f; + // ── 自適應 EMA 偏移補償 ──────────────────────────────────────────────── + // 平滑係數與公式集中於 GamepadBiasSmoothing,由 XInput 與 GameInput 兩路共用,確保行為一致; + // 本類別只保留與輸入數值尺度相關的誤差範圍與學習閾值。 /// /// 觸發全速學習的誤差閾值(偏移誤差達此值時使用最大係數)。 @@ -428,9 +381,9 @@ internal sealed partial class GameInputGamepadController : IGamepadController private bool _supportsRumble = false; /// - /// 熱負載估算倍率(依支援馬達數量調整,2 馬達約 2.0、4 馬達約 4.0)。 + /// 熱負載估算倍率(依支援馬達數量正規化,以雙主馬達為 1.0,4 馬達為 2.0)。 /// - private double _rumbleThermalWeight = 2.0; + private double _rumbleThermalWeight = VibrationSafetyLimiter.GetMotorThermalCostMultiplier(2); /// /// 取得或設定搖桿進入死區閾值 @@ -2130,7 +2083,7 @@ private void UpdateDeviceInfo() _cachedDeviceIdentity = string.Empty; _supportsRumble = false; - _rumbleThermalWeight = 2.0; + _rumbleThermalWeight = VibrationSafetyLimiter.GetMotorThermalCostMultiplier(2); return; } @@ -2143,7 +2096,7 @@ private void UpdateDeviceInfo() _supportsRumble = info.SupportedRumbleMotors != 0; uint supportedMotorBits = Convert.ToUInt32(info.SupportedRumbleMotors); int supportedMotorCount = BitOperations.PopCount(supportedMotorBits); - _rumbleThermalWeight = Math.Clamp(supportedMotorCount, 1, 4); + _rumbleThermalWeight = VibrationSafetyLimiter.GetMotorThermalCostMultiplier(supportedMotorCount); // 更新裝置名稱與穩定識別資訊。Auto 判斷改以 VID/PID 與 GameInput // 原始顯示名稱作為穩定線索。 @@ -2158,7 +2111,7 @@ private void UpdateDeviceInfo() _cachedDeviceName = "Unknown Gamepad"; _cachedDeviceIdentity = _cachedDeviceName; _supportsRumble = false; - _rumbleThermalWeight = 2.0; + _rumbleThermalWeight = VibrationSafetyLimiter.GetMotorThermalCostMultiplier(2); } } @@ -2237,28 +2190,28 @@ private void UpdateStickBias( { float errorX = rawLeftThumbX - _leftStickBiasX; _leftStickBiasX += errorX * ComputeAdaptiveBiasSmoothing( - errorX, LeftStickBiasXBaseSmoothing, LeftStickBiasXMaxSmoothing); + errorX, GamepadBiasSmoothing.LeftStickBiasXBaseSmoothing, GamepadBiasSmoothing.LeftStickBiasXMaxSmoothing); } if (!isDPadActive && MathF.Abs(rawLeftThumbY) <= LeftStickBiasLearningThreshold) { float errorY = rawLeftThumbY - _leftStickBiasY; _leftStickBiasY += errorY * ComputeAdaptiveBiasSmoothing( - errorY, LeftStickBiasYBaseSmoothing, LeftStickBiasYMaxSmoothing); + errorY, GamepadBiasSmoothing.LeftStickBiasYBaseSmoothing, GamepadBiasSmoothing.LeftStickBiasYMaxSmoothing); } if (MathF.Abs(rawRightThumbX) <= LeftStickBiasLearningThreshold) { float errorRX = rawRightThumbX - _rightStickBiasX; _rightStickBiasX += errorRX * ComputeAdaptiveBiasSmoothing( - errorRX, RightStickBiasBaseSmoothing, RightStickBiasMaxSmoothing); + errorRX, GamepadBiasSmoothing.RightStickBiasBaseSmoothing, GamepadBiasSmoothing.RightStickBiasMaxSmoothing); } if (MathF.Abs(rawRightThumbY) <= LeftStickBiasLearningThreshold) { float errorRY = rawRightThumbY - _rightStickBiasY; _rightStickBiasY += errorRY * ComputeAdaptiveBiasSmoothing( - errorRY, RightStickBiasYBaseSmoothing, RightStickBiasYMaxSmoothing); + errorRY, GamepadBiasSmoothing.RightStickBiasYBaseSmoothing, GamepadBiasSmoothing.RightStickBiasYMaxSmoothing); } } @@ -2275,8 +2228,11 @@ private static float ComputeAdaptiveBiasSmoothing( float baseSmoothing, float maxSmoothing) { - float t = Math.Clamp(MathF.Abs(error) / BiasAdaptiveErrorRange, 0f, 1f); - return baseSmoothing + (maxSmoothing - baseSmoothing) * t; + return GamepadBiasSmoothing.ComputeAdaptiveSmoothing( + error, + BiasAdaptiveErrorRange, + baseSmoothing, + maxSmoothing); } /// diff --git a/src/InputBox/Core/Input/GamepadBiasSmoothing.cs b/src/InputBox/Core/Input/GamepadBiasSmoothing.cs new file mode 100644 index 0000000..a31fd2b --- /dev/null +++ b/src/InputBox/Core/Input/GamepadBiasSmoothing.cs @@ -0,0 +1,73 @@ +namespace InputBox.Core.Input; + +/// +/// XInput 與 GameInput 共用的自適應 EMA 搖桿偏移補償係數與學習率公式 +/// +/// +/// 每個軸保有獨立的「基礎值」與「最大值」:估計誤差(原始值 − 目前偏移)落在誤差範圍內時, +/// 學習率由基礎值線性插值至最大值,誤差越大收斂越快;誤差接近 0 時退回基礎值,避免把有效輸入誤學成硬體偏移。 +/// 兩個後端的輸入數值尺度不同(XInput 為 short、GameInput 為 -1~1 浮點數), +/// 因此誤差範圍由各後端自行提供;平滑係數則必須完全一致,集中於此避免兩邊各自漂移。 +/// +internal static class GamepadBiasSmoothing +{ + /// + /// 左搖桿 X 軸:偏移估計的最低保守學習率(誤差接近 0 時使用)。 + /// + public const float LeftStickBiasXBaseSmoothing = 0.03f; + + /// + /// 左搖桿 X 軸:偏移估計的最高學習率(誤差達到誤差範圍時使用)。 + /// + public const float LeftStickBiasXMaxSmoothing = 0.15f; + + /// + /// 左搖桿 Y 軸:偏移估計的最低保守學習率。 + /// + public const float LeftStickBiasYBaseSmoothing = 0.03f; + + /// + /// 左搖桿 Y 軸:偏移估計的最高學習率。 + /// + public const float LeftStickBiasYMaxSmoothing = 0.12f; + + /// + /// 右搖桿 X 軸:偏移估計的最低保守學習率。 + /// + public const float RightStickBiasBaseSmoothing = 0.05f; + + /// + /// 右搖桿 X 軸:偏移估計的最高學習率。 + /// 右搖桿無 D-Pad 閘門,低最大值可防止快速劃過中立區時累積偏移。 + /// + public const float RightStickBiasMaxSmoothing = 0.07f; + + /// + /// 右搖桿 Y 軸:偏移估計的最低保守學習率。 + /// + public const float RightStickBiasYBaseSmoothing = 0.05f; + + /// + /// 右搖桿 Y 軸:偏移估計的最高學習率。 + /// + public const float RightStickBiasYMaxSmoothing = 0.07f; + + /// + /// 依偏移誤差計算自適應學習率:α = base + (max − base) × clamp(|error| / errorRange, 0, 1)。 + /// + /// 目前估計誤差(與 使用相同尺度)。 + /// 觸發最大學習率的誤差範圍;必須大於 0。 + /// 誤差接近 0 時使用的最低學習率。 + /// 誤差達到範圍上限時使用的最高學習率。 + /// 本幀使用的學習率。 + public static float ComputeAdaptiveSmoothing( + float error, + float errorRange, + float baseSmoothing, + float maxSmoothing) + { + float t = Math.Clamp(MathF.Abs(error) / errorRange, 0f, 1f); + + return baseSmoothing + (maxSmoothing - baseSmoothing) * t; + } +} diff --git a/src/InputBox/Core/Input/VibrationSafetyLimiter.cs b/src/InputBox/Core/Input/VibrationSafetyLimiter.cs index 195df0e..3d4b39a 100644 --- a/src/InputBox/Core/Input/VibrationSafetyLimiter.cs +++ b/src/InputBox/Core/Input/VibrationSafetyLimiter.cs @@ -54,7 +54,17 @@ internal enum VibrationLimiterFlags /// /// 因可感知體驗保底而回補。 /// - ScaledByPerceptibilityFloor = 1 << 8 + ScaledByPerceptibilityFloor = 1 << 8, + + /// + /// 單次請求超出剩餘熱預算,已降低強度以符合預算。 + /// + ScaledByThermalOverflow = 1 << 9, + + /// + /// 短時間內接連出現的 Critical 請求被視為自動連發,已降為 Normal 交由熱保護節流。 + /// + DowngradedCriticalRepeat = 1 << 10 } /// @@ -100,6 +110,26 @@ internal sealed class VibrationSafetyLimiter /// private const int AmbientPerceptibleFloorDurationMs = 35; + /// + /// 熱成本倍率的基準馬達數量(雙主馬達控制器,例如 XInput)。 + /// + private const int ReferenceMotorCount = 2; + + /// + /// 熱負載溢出判斷相對於硬上限的容許倍率。 + /// + private const double ThermalOverflowTolerance = 1.05; + + /// + /// 判定 Critical 請求為自動連發的時間視窗(毫秒)。 + /// + /// + /// 例如在空白輸入框連按 B 時,操作失敗回饋約每 150ms 觸發一次;若仍以 Critical 送出, + /// 會不受熱保護限制地連續以高強度驅動馬達。不論 Critical 的強度與時長是否相同都一併判定, + /// 避免不同 Critical 模式交錯出現時規避節流。視窗會隨每次連發延長,停手超過此時間後才恢復為 Critical。 + /// + private const int CriticalRepeatWindowMs = 500; + private readonly Lock _lock = new(); private readonly Queue<(long EndMs, int DurationMs)> _acceptedDurations = new(); @@ -114,6 +144,7 @@ internal sealed class VibrationSafetyLimiter private double _thermalLoad; private long _lastSampleMs; private long _ambientCooldownUntilMs; + private long _lastCriticalRequestMs = long.MinValue; /// /// 建立震動保護器。 @@ -140,6 +171,20 @@ public VibrationSafetyLimiter( _ambientCooldownMs = Math.Max(ambientCooldownMs, 0); } + /// + /// 依控制器回報的震動馬達數量取得正規化的熱成本倍率。 + /// + /// + /// 以雙主馬達為基準(倍率 1.0),讓預算與縮放參數在不同後端之間具有相同意義; + /// 否則多馬達裝置的單次提示成本可能直接超過硬上限,導致冷啟動狀態下也被拒絕。 + /// + /// 支援的震動馬達數量;會被限制在 1 到 4 之間。 + /// 正規化後的熱成本倍率(0.5 到 2.0)。 + public static double GetMotorThermalCostMultiplier(int motorCount) + { + return Math.Clamp(motorCount, 1, 4) / (double)ReferenceMotorCount; + } + /// /// 依目前保護狀態嘗試套用震動請求,必要時自動縮減強度與持續時間。 /// @@ -273,6 +318,24 @@ internal bool TryApplyWithDiagnostics( DecayThermal(nowMs); PruneDutyWindow(nowMs); + // 前一個 Critical 請求之後的連發視窗內再次出現 Critical 時視為自動連發,降為 Normal 交由熱保護與占空比節流。 + // 第一次仍以 Critical 完整送出;冷啟動時 Normal 也會完整送出,因此一般節奏的操作與短序列不受影響。 + bool downgradedCriticalRepeat = false; + + if (priority == VibrationPriority.Critical) + { + bool isRepeat = _lastCriticalRequestMs != long.MinValue && + nowMs - _lastCriticalRequestMs < CriticalRepeatWindowMs; + + _lastCriticalRequestMs = nowMs; + + if (isRepeat) + { + priority = VibrationPriority.Normal; + downgradedCriticalRepeat = true; + } + } + if (priority == VibrationPriority.Ambient && nowMs < _ambientCooldownUntilMs) { @@ -288,7 +351,9 @@ internal bool TryApplyWithDiagnostics( } double scale = 1.0; - VibrationLimiterFlags flags = VibrationLimiterFlags.None; + VibrationLimiterFlags flags = downgradedCriticalRepeat ? + VibrationLimiterFlags.DowngradedCriticalRepeat : + VibrationLimiterFlags.None; double currentDuty = _windowOnTimeMs / _windowMs; @@ -415,12 +480,36 @@ internal bool TryApplyWithDiagnostics( } } - double amplitude = candidateStrength / 65535.0; double clampedMultiplier = Math.Clamp(thermalCostMultiplier, 0.25, 8.0); - double cost = amplitude * amplitude * candidateDuration * clampedMultiplier; + double cost = ComputeThermalCost(candidateStrength, candidateDuration, clampedMultiplier); + double overflowLimit = _thermalHardBudget * ThermalOverflowTolerance; + + // Normal 優先級單次請求超出剩餘熱預算時,先依剩餘預算降低振幅(熱成本與振幅平方成正比), + // 只有降到優先級保底比例以下(代表馬達已接近過熱)才拒絕,避免冷啟動時重要回饋被整個吞掉。 + if (priority == VibrationPriority.Normal && + _thermalLoad + cost > overflowLimit) + { + double headroom = overflowLimit - _thermalLoad; + double fitScale = headroom > 0.0 ? + Math.Sqrt(headroom / cost) : + 0.0; + + if (scale * fitScale >= minScale) + { + ushort fittedStrength = (ushort)Math.Max(1, (int)Math.Floor(candidateStrength * fitScale)); + + if (fittedStrength < candidateStrength) + { + candidateStrength = fittedStrength; + cost = ComputeThermalCost(candidateStrength, candidateDuration, clampedMultiplier); + scale *= fitScale; + flags |= VibrationLimiterFlags.ScaledByThermalOverflow; + } + } + } if (priority != VibrationPriority.Critical && - _thermalLoad + cost > _thermalHardBudget * 1.05) + _thermalLoad + cost > overflowLimit) { _ambientCooldownUntilMs = nowMs + _ambientCooldownMs; flags |= VibrationLimiterFlags.BlockedByThermalOverflow; @@ -454,6 +543,20 @@ internal bool TryApplyWithDiagnostics( } } + /// + /// 計算單次震動的熱成本(振幅平方 × 持續時間 × 馬達倍率)。 + /// + /// 震動強度(0 到 65535)。 + /// 持續時間(毫秒)。 + /// 已限制範圍的熱成本倍率。 + /// 熱成本估值。 + private static double ComputeThermalCost(ushort strength, int durationMs, double multiplier) + { + double amplitude = strength / 65535.0; + + return amplitude * amplitude * durationMs * multiplier; + } + /// /// 重置限制器的熱負載、占空比與冷卻狀態。 /// @@ -466,6 +569,7 @@ public void Reset() _thermalLoad = 0.0; _lastSampleMs = 0; _ambientCooldownUntilMs = 0; + _lastCriticalRequestMs = long.MinValue; } } diff --git a/src/InputBox/Core/Input/XInputGamepadController.cs b/src/InputBox/Core/Input/XInputGamepadController.cs index 9a49d93..5f8e383 100644 --- a/src/InputBox/Core/Input/XInputGamepadController.cs +++ b/src/InputBox/Core/Input/XInputGamepadController.cs @@ -34,12 +34,12 @@ internal sealed partial class XInputGamepadController : IGamepadController private readonly GamepadRepeatSettings _repeatSettings; /// - /// 目前動態計算的連發間隔幀數(加入生理抖動) + /// 目前生效的連發間隔幀數(首次為初始延遲,之後為重複間隔) /// private int _currentRepeatInterval; /// - /// 目前動態計算的右搖桿連發間隔幀數(加入生理抖動) + /// 目前生效的右搖桿連發間隔幀數(首次為初始延遲,之後為重複間隔) /// private int _currentRSRepeatInterval; @@ -276,53 +276,9 @@ internal sealed partial class XInputGamepadController : IGamepadController /// private const int MappedDirectionSuppressCooldownFrames = 12; - // ── 自適應 EMA 係數(Adaptive Exponential Moving Average)──────────────── - // 設計原則:每個軸保有獨立的「基礎值」與「最大值」。 - // • 當估計誤差(rawValue − currentBias)落在 BiasAdaptiveErrorRange 以內時, - // 學習率從 Base 線性插值至 Max,誤差越大學習越快(快速收斂)。 - // • 當誤差接近 0 時,退回 Base(保守維持),避免把有效輸入誤學成硬體偏移。 - // • 係數與 GameInputGamepadController 對齊,確保兩路控制器行為一致。 - - /// - /// 左搖桿 X 軸:偏移估計的最低保守學習率(誤差接近 0 時使用)。 - /// - private const float LeftStickBiasXBaseSmoothing = 0.03f; - - /// - /// 左搖桿 X 軸:偏移估計的最高學習率(誤差達到 BiasAdaptiveErrorRange 時使用)。 - /// - private const float LeftStickBiasXMaxSmoothing = 0.15f; - - /// - /// 左搖桿 Y 軸:偏移估計的最低保守學習率。 - /// - private const float LeftStickBiasYBaseSmoothing = 0.03f; - - /// - /// 左搖桿 Y 軸:偏移估計的最高學習率。 - /// - private const float LeftStickBiasYMaxSmoothing = 0.12f; - - /// - /// 右搖桿 X 軸:偏移估計的最低保守學習率。 - /// - private const float RightStickBiasBaseSmoothing = 0.05f; - - /// - /// 右搖桿 X 軸:偏移估計的最高學習率。 - /// 右搖桿無 D-Pad 閘門,低 Max 可防止快速劃過中立區時累積偏移。 - /// - private const float RightStickBiasMaxSmoothing = 0.07f; - - /// - /// 右搖桿 Y 軸:偏移估計的最低保守學習率。 - /// - private const float RightStickBiasYBaseSmoothing = 0.05f; - - /// - /// 右搖桿 Y 軸:偏移估計的最高學習率。 - /// - private const float RightStickBiasYMaxSmoothing = 0.07f; + // ── 自適應 EMA 偏移補償 ──────────────────────────────────────────────── + // 平滑係數與公式集中於 GamepadBiasSmoothing,由 XInput 與 GameInput 兩路共用,確保行為一致; + // 本類別只保留與輸入數值尺度相關的誤差範圍與學習閾值。 /// /// 觸發全速學習的誤差閾值(short 尺度;對應 float 空間的 0.05,即 0.05 × 32767 ≈ 1638)。 @@ -1334,28 +1290,28 @@ private void UpdateStickBias( { float errorLX = rawLeftThumbX - _leftStickBiasX; _leftStickBiasX += errorLX * ComputeAdaptiveBiasSmoothing( - errorLX, LeftStickBiasXBaseSmoothing, LeftStickBiasXMaxSmoothing); + errorLX, GamepadBiasSmoothing.LeftStickBiasXBaseSmoothing, GamepadBiasSmoothing.LeftStickBiasXMaxSmoothing); } if (!isDPadActive && Math.Abs((int)rawLeftThumbY) <= LeftStickBiasLearningThreshold) { float errorLY = rawLeftThumbY - _leftStickBiasY; _leftStickBiasY += errorLY * ComputeAdaptiveBiasSmoothing( - errorLY, LeftStickBiasYBaseSmoothing, LeftStickBiasYMaxSmoothing); + errorLY, GamepadBiasSmoothing.LeftStickBiasYBaseSmoothing, GamepadBiasSmoothing.LeftStickBiasYMaxSmoothing); } if (Math.Abs((int)rawRightThumbX) <= LeftStickBiasLearningThreshold) { float errorRX = rawRightThumbX - _rightStickBiasX; _rightStickBiasX += errorRX * ComputeAdaptiveBiasSmoothing( - errorRX, RightStickBiasBaseSmoothing, RightStickBiasMaxSmoothing); + errorRX, GamepadBiasSmoothing.RightStickBiasBaseSmoothing, GamepadBiasSmoothing.RightStickBiasMaxSmoothing); } if (Math.Abs((int)rawRightThumbY) <= LeftStickBiasLearningThreshold) { float errorRY = rawRightThumbY - _rightStickBiasY; _rightStickBiasY += errorRY * ComputeAdaptiveBiasSmoothing( - errorRY, RightStickBiasYBaseSmoothing, RightStickBiasYMaxSmoothing); + errorRY, GamepadBiasSmoothing.RightStickBiasYBaseSmoothing, GamepadBiasSmoothing.RightStickBiasYMaxSmoothing); } } @@ -1372,8 +1328,11 @@ private static float ComputeAdaptiveBiasSmoothing( float baseSmoothing, float maxSmoothing) { - float t = Math.Clamp(MathF.Abs(error) / BiasAdaptiveErrorRange, 0f, 1f); - return baseSmoothing + (maxSmoothing - baseSmoothing) * t; + return GamepadBiasSmoothing.ComputeAdaptiveSmoothing( + error, + BiasAdaptiveErrorRange, + baseSmoothing, + maxSmoothing); } /// @@ -1895,7 +1854,7 @@ public Task VibrateAsync( out ushort safeStrength, out int safeDurationMs, out VibrationLimiterDebugInfo limiterDiagnostics, - thermalCostMultiplier: 2.0); + thermalCostMultiplier: VibrationSafetyLimiter.GetMotorThermalCostMultiplier(2)); #if DEBUG bool enableDebugDiagnostics = diff --git a/src/InputBox/Core/Services/RestartActivationCoordinator.cs b/src/InputBox/Core/Services/RestartActivationCoordinator.cs index 5a64459..bcb3b3f 100644 --- a/src/InputBox/Core/Services/RestartActivationCoordinator.cs +++ b/src/InputBox/Core/Services/RestartActivationCoordinator.cs @@ -97,6 +97,34 @@ public bool ConsumePendingActivationRequest() } } + /// + /// 查詢是否有尚未過期的重啟啟用請求,但不消費標記。 + /// + /// + /// 供其他正在啟動的執行個體判斷「目前是否有程式內重啟交接進行中」, + /// 標記仍保留給重啟後的新執行個體消費。 + /// + /// 若存在且尚未過期的請求則回傳 true。 + public bool HasPendingActivationRequest() + { + try + { + if (!File.Exists(_markerPath)) + { + return false; + } + + string payload = File.ReadAllText(_markerPath).Trim(); + + return long.TryParse(payload, NumberStyles.Integer, CultureInfo.InvariantCulture, out long expiryTicks) && + expiryTicks >= DateTime.UtcNow.Ticks; + } + catch + { + return false; + } + } + /// /// 清除殘留標記(若存在)。 /// diff --git a/src/InputBox/Core/Services/RestartProcessLauncher.cs b/src/InputBox/Core/Services/RestartProcessLauncher.cs new file mode 100644 index 0000000..5b18af5 --- /dev/null +++ b/src/InputBox/Core/Services/RestartProcessLauncher.cs @@ -0,0 +1,87 @@ +using System.Diagnostics; + +namespace InputBox.Core.Services; + +/// +/// 負責程式內重啟時啟動新的執行個體;取代 以便在舊視窗關閉前取得新程序識別碼, +/// 讓前景授權只交給該程序,而不必開放給所有程序;並以交接參數讓新執行個體等待接手單一執行個體 Mutex。 +/// +internal static class RestartProcessLauncher +{ + /// + /// 依目前執行檔與命令列引數建立重啟用的啟動資訊。 + /// + /// + /// 與 相同:以同一個執行檔啟動,並只轉送第一個元素(執行檔本身)之後的引數; + /// 改用 逐一加入,避免引數內含引號時被錯誤拼接。 + /// + /// 要啟動的執行檔完整路徑。 + /// 目前程序的命令列引數(含第一個元素的執行檔路徑)。 + /// + /// 舊執行個體的程序識別碼;指定時會附加 交接參數, + /// 讓新執行個體等待並接手單一執行個體 Mutex。既有的交接參數一律先移除,避免連續重啟時重複轉送。 + /// + /// 重啟用的啟動資訊。 + public static ProcessStartInfo CreateStartInfo( + string executablePath, + IReadOnlyList commandLineArgs, + int? handoffProcessId = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(executablePath); + ArgumentNullException.ThrowIfNull(commandLineArgs); + + ProcessStartInfo startInfo = new(executablePath) + { + UseShellExecute = false + }; + + IReadOnlyList forwardedArgs = SingleInstanceHandoff.RemoveHandoffArguments(commandLineArgs); + + for (int i = 1; i < forwardedArgs.Count; i++) + { + startInfo.ArgumentList.Add(forwardedArgs[i]); + } + + if (handoffProcessId.HasValue) + { + startInfo.ArgumentList.Add(SingleInstanceHandoff.CreateHandoffArgument(handoffProcessId.Value)); + } + + return startInfo; + } + + /// + /// 嘗試啟動新的執行個體並取得其程序識別碼。 + /// + /// 由 建立的啟動資訊。 + /// 成功時為新程序的識別碼;失敗時為 0。 + /// 若已成功啟動新程序則回傳 true。 + public static bool TryStart( + ProcessStartInfo startInfo, + out int processId) + { + ArgumentNullException.ThrowIfNull(startInfo); + + processId = 0; + + try + { + using Process? process = Process.Start(startInfo); + + if (process == null) + { + return false; + } + + processId = process.Id; + + return true; + } + catch (Exception ex) + { + LoggerService.LogException(ex, "程式內重啟時無法啟動新的執行個體"); + + return false; + } + } +} diff --git a/src/InputBox/Core/Services/SingleInstanceHandoff.cs b/src/InputBox/Core/Services/SingleInstanceHandoff.cs new file mode 100644 index 0000000..b967f1d --- /dev/null +++ b/src/InputBox/Core/Services/SingleInstanceHandoff.cs @@ -0,0 +1,171 @@ +using System.Globalization; + +namespace InputBox.Core.Services; + +/// +/// 單一執行個體啟動時應採取的動作。 +/// +internal enum SingleInstanceStartupAction +{ + /// + /// 已取得單一執行個體 Mutex,繼續正常啟動。 + /// + Proceed, + + /// + /// 本執行個體是程式內重啟的接手者,應等待舊執行個體釋放 Mutex 後接手,不喚醒既有實例。 + /// + WaitForHandoff, + + /// + /// 已有其他執行個體持有 Mutex,應嘗試喚醒既有實例。 + /// + ActivateExisting, +} + +/// +/// 喚醒既有實例失敗後的處置。 +/// +internal enum SingleInstanceFallbackDecision +{ + /// + /// 允許以 fallback 方式繼續啟動新視窗,避免使用者看不到任何畫面。 + /// + TryFallback, + + /// + /// 已找到可喚醒視窗但前景切換被系統阻擋,結束本次啟動以維持單一實例。 + /// + ExitForegroundBlocked, + + /// + /// 程式內重啟交接進行中,接手的新執行個體會自行顯示視窗,結束本次啟動以免出現兩個視窗。 + /// + ExitRestartHandoffPending, +} + +/// +/// 程式內重啟的單一執行個體交接規則。 +/// +/// +/// 舊執行個體在持有 Mutex 的情況下,以 參數啟動新執行個體, +/// 並於關閉所有視窗後才釋放 Mutex;新執行個體據此等待並接手 Mutex,而不是把 Mutex 視為「已有實例」而喚醒後退出。 +/// 如此可避免舊實例先釋放 Mutex 時,恰好另外啟動的執行個體搶先取得 Mutex,導致重啟出來的實例退出。 +/// +internal static class SingleInstanceHandoff +{ + /// + /// 標記「本執行個體由程式內重啟啟動」的命令列參數前綴,後接舊執行個體的程序識別碼。 + /// + internal const string HandoffArgumentPrefix = "--restart-handoff="; + + /// + /// 新執行個體等待舊執行個體釋放 Mutex 的上限。 + /// + internal static readonly TimeSpan HandoffWaitTimeout = TimeSpan.FromSeconds(10); + + /// + /// 建立交接參數。 + /// + /// 舊執行個體的程序識別碼。 + /// 交接參數字串。 + public static string CreateHandoffArgument(int processId) + { + return HandoffArgumentPrefix + processId.ToString(CultureInfo.InvariantCulture); + } + + /// + /// 從命令列引數中取得交接來源的程序識別碼。 + /// + /// 命令列引數(第一個元素為執行檔本身,會被略過)。 + /// 找到有效交接參數時為舊執行個體的程序識別碼;否則為 0。 + /// 若包含有效的交接參數則回傳 true。 + public static bool TryGetHandoffProcessId(IReadOnlyList commandLineArgs, out int processId) + { + ArgumentNullException.ThrowIfNull(commandLineArgs); + + processId = 0; + + for (int i = 1; i < commandLineArgs.Count; i++) + { + string arg = commandLineArgs[i]; + + if (arg.StartsWith(HandoffArgumentPrefix, StringComparison.Ordinal) && + int.TryParse( + arg.AsSpan(HandoffArgumentPrefix.Length), + NumberStyles.None, + CultureInfo.InvariantCulture, + out int parsed) && + parsed > 0) + { + processId = parsed; + + return true; + } + } + + return false; + } + + /// + /// 移除命令列引數中的交接參數,避免連續重啟時交接參數被重複轉送。 + /// + /// 命令列引數(第一個元素為執行檔本身,會原樣保留)。 + /// 不含交接參數的命令列引數。 + public static IReadOnlyList RemoveHandoffArguments(IReadOnlyList commandLineArgs) + { + ArgumentNullException.ThrowIfNull(commandLineArgs); + + List result = new(commandLineArgs.Count); + + for (int i = 0; i < commandLineArgs.Count; i++) + { + if (i > 0 && + commandLineArgs[i].StartsWith(HandoffArgumentPrefix, StringComparison.Ordinal)) + { + continue; + } + + result.Add(commandLineArgs[i]); + } + + return result; + } + + /// + /// 依 Mutex 取得結果與是否為交接啟動,決定啟動動作。 + /// + /// 是否新建並取得單一執行個體 Mutex。 + /// 是否帶有有效的交接參數。 + /// 應採取的啟動動作。 + public static SingleInstanceStartupAction ResolveStartupAction(bool createdNew, bool isHandoffLaunch) + { + if (createdNew) + { + return SingleInstanceStartupAction.Proceed; + } + + return isHandoffLaunch ? + SingleInstanceStartupAction.WaitForHandoff : + SingleInstanceStartupAction.ActivateExisting; + } + + /// + /// 喚醒既有實例失敗後,決定是否允許 fallback 啟動新視窗。 + /// + /// 喚醒流程判定是否允許 fallback(例如找不到任何可喚醒視窗)。 + /// 目前是否有程式內重啟交接進行中。 + /// 喚醒失敗後的處置。 + public static SingleInstanceFallbackDecision ResolveFallback(bool fallbackPermitted, bool restartHandoffPending) + { + if (!fallbackPermitted) + { + return SingleInstanceFallbackDecision.ExitForegroundBlocked; + } + + // 交接期間舊實例的視窗已關閉、新實例尚未顯示視窗,若此時允許 fallback 會多開一個視窗。 + return restartHandoffPending ? + SingleInstanceFallbackDecision.ExitRestartHandoffPending : + SingleInstanceFallbackDecision.TryFallback; + } +} diff --git a/src/InputBox/Core/Services/WindowFocusService.cs b/src/InputBox/Core/Services/WindowFocusService.cs index 9c3311e..836c067 100644 --- a/src/InputBox/Core/Services/WindowFocusService.cs +++ b/src/InputBox/Core/Services/WindowFocusService.cs @@ -150,7 +150,7 @@ public static async Task RestoreWindowAsync( { // 第一段:先用低侵入方式切換,避免不必要的執行緒附加。 // 僅在視窗確實處於最小化狀態時才呼叫 SW_RESTORE, - // 避免對全螢幕(Xbox 達陣全螢幕)或最大化視窗呼叫後造成畫面跟版。 + // 避免對全螢幕(例如 Xbox 模式)或最大化視窗呼叫後造成畫面跟版。 if (User32.IsIconic(targetHwnd)) { _ = User32.ShowWindow(targetHwnd, User32.ShowWindowCommand.Restore); diff --git a/src/InputBox/Core/Utilities/GaussianDelayHelper.cs b/src/InputBox/Core/Utilities/GaussianDelayHelper.cs index 10406da..d59f7df 100644 --- a/src/InputBox/Core/Utilities/GaussianDelayHelper.cs +++ b/src/InputBox/Core/Utilities/GaussianDelayHelper.cs @@ -2,7 +2,7 @@ /// /// 提供基於高斯分佈(常態分佈)的隨機數產生器 -/// 用於系統操作的自然緩衝延遲,改善 A11y 音訊避讓與 UI 執行緒排程穩定性 +/// 用於本程式內部排程的緩衝延遲,改善 A11y 音訊避讓與 UI 執行緒排程穩定性 /// internal static class GaussianDelayHelper { @@ -30,11 +30,15 @@ public static double NextGaussian( } /// - /// 產生符合人類反應特徵的毫秒延遲 + /// 產生加入排程抖動的毫秒延遲 /// + /// + /// 僅用於本程式內部的 A11y 廣播避讓時序(), + /// 讓連續廣播不會固定落在同一個排程點;不參與任何對其他視窗的輸入或輸出。 + /// /// 基礎延遲(毫秒) /// 抖動範圍(毫秒) - /// 加上生理擾動後的延遲時間 + /// 加上排程抖動後的延遲時間 public static int NextDelay( int baseDelay, int jitterRange) @@ -51,7 +55,7 @@ public static int NextDelay( double mean = baseDelay + (jitterRange / 2.0), sd = jitterRange / 6.0; - // 若 jitter 過小導致 sd 太低,補上一個極小生理抖動(平均值的 5%)。 + // 若 jitter 過小導致 sd 太低,補上最小排程抖動(平均值的 5%)。 sd = Math.Max(sd, mean * 0.05); double result = NextGaussian(mean, sd); diff --git a/src/InputBox/InputBox.csproj b/src/InputBox/InputBox.csproj index bd763f3..f874e3b 100644 --- a/src/InputBox/InputBox.csproj +++ b/src/InputBox/InputBox.csproj @@ -53,7 +53,7 @@ - + diff --git a/src/InputBox/MainForm.ContextMenu.cs b/src/InputBox/MainForm.ContextMenu.cs index 7b2dcfd..0d031d7 100644 --- a/src/InputBox/MainForm.ContextMenu.cs +++ b/src/InputBox/MainForm.ContextMenu.cs @@ -8,6 +8,7 @@ using InputBox.Core.Utilities; using InputBox.Resources; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Media; namespace InputBox; @@ -134,6 +135,158 @@ private void InitializeContextMenu() }, RestartMenuAccessibleDescription); + InitializeToggleMenuItems(); + + ToolStripMenuItem tsmiHotkeySettings = CreateHotkeySettingsMenu(); + ToolStripMenuItem tsmiSettings = CreateSettingsMenu(); + + // 清除歷程。 + // 清空目前只保存在記憶體中的輸入歷程資料。 + ToolStripMenuItem tsmiClearHistory = new(ControlExtensions.GetMnemonicText(Strings.Menu_ClearHistory, 'C')) + { + Name = "TsmiClearHistory", + AccessibleName = Strings.Menu_ClearHistory, + AccessibleDescription = Strings.Menu_ClearHistory_Desc + }; + tsmiClearHistory.Click += (s, e) => + { + try + { + _historyService?.Clear(); + + // 清除後主動將焦點拉回輸入框,確保使用者能直接開始輸入。 + TBInput.Focus(); + + FeedbackService.PlaySound(SystemSounds.Asterisk); + + AnnounceA11y(Strings.Msg_InputCleared); + } + catch (Exception ex) + { + Debug.WriteLine($"[選單] tsmiClearHistory.Click 失敗:{ex.Message}"); + } + }; + + // 離開。 + // 關閉主視窗並結束整個應用程式流程。 + ToolStripMenuItem tsmiExit = new(ControlExtensions.GetMnemonicText(Strings.Menu_Exit, 'X')) + { + AccessibleName = Strings.Menu_Exit, + AccessibleDescription = Strings.A11y_Menu_Exit_Desc + }; + tsmiExit.Click += (s, e) => + { + try + { + Close(); + } + catch (Exception ex) + { + Debug.WriteLine($"[選單] tsmiExit.Click 失敗:{ex.Message}"); + } + }; + + // 說明(WCAG 3.3.5)。 + // 顯示鍵盤與遊戲控制器操作對照的說明對話框。 + ToolStripMenuItem tsmiHelp = new(ControlExtensions.GetMnemonicText(Strings.Menu_Help, 'H')) + { + AccessibleName = Strings.Menu_Help, + AccessibleDescription = Strings.Menu_Help_Desc + }; + tsmiHelp.Click += (s, e) => + { + try + { + ShowHelpDialog(); + } + catch (Exception ex) + { + Debug.WriteLine($"[選單] tsmiHelp.Click 失敗:{ex.Message}"); + } + }; + + // 使用共享快取取得選單字型。 + _cmsInput.Font = GetSharedA11yFont(DeviceDpi); + _cmsInput.Opened += (s, e) => EnsureContextMenuReadyForKeyboard(_cmsInput); + _cmsInput.PreviewKeyDown += ContextMenu_PreviewKeyDown; + _cmsInput.KeyDown += ContextMenu_KeyDown; + _cmsInput.Closed += (s, e) => RestorePhraseSubMenuAutoClose(); + _cmsInput.Closing += (s, e) => + { + try + { + if (ShouldSuppressPhraseMenuClose(e.CloseReason)) + { + e.Cancel = true; + } + } + catch (Exception ex) + { + Debug.WriteLine($"[選單] _cmsInput.Closing 失敗:{ex.Message}"); + } + }; + + InitializePhrasesMenu(); + + 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); + _cmsInput.Items.Add(_tsmiMinimizeOnReturn); + _cmsInput.Items.Add(new ToolStripSeparator()); + _cmsInput.Items.Add(_tsmiPhrases); + _cmsInput.Items.Add(new ToolStripSeparator()); + _cmsInput.Items.Add(tsmiHotkeySettings); + _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()); + _cmsInput.Items.Add(tsmiExit); + + // 綁定選單至容器控制項,確保 TBInput 能保留其原始的 Windows 右鍵選單(剪下、複製、貼上)。 + PInputHost.ContextMenuStrip = _cmsInput; + TLPHost.ContextMenuStrip = _cmsInput; + } + + /// + /// 建立右鍵選單頂層的切換項目:隱私模式、允許中斷廣播、動畫式視覺警示與返回時最小化 + /// + [MemberNotNull( + nameof(_tsmiPrivacyMode), + nameof(_tsmiA11yInterrupt), + nameof(_tsmiAnimatedVisualAlerts), + nameof(_tsmiMinimizeOnReturn))] + private void InitializeToggleMenuItems() + { // 隱私模式。 _tsmiPrivacyMode = new ToolStripMenuItem(ControlExtensions.GetMnemonicText(Strings.Menu_PrivacyMode, 'P')) { @@ -298,7 +451,14 @@ private void InitializeContextMenu() Debug.WriteLine($"[選單] _tsmiMinimizeOnReturn.CheckedChanged 失敗:{ex.Message}"); } }; + } + /// + /// 建立右鍵選單的「快速鍵設定」子選單 + /// + /// 快速鍵設定子選單項目。 + private ToolStripMenuItem CreateHotkeySettingsMenu() + { // 快速鍵設定子選單。 // 提供修飾鍵與主按鍵擷取等快速鍵相關設定入口。 ToolStripMenuItem tsmiHotkeySettings = new(ControlExtensions.GetMnemonicText(Strings.Menu_HotkeySettings, 'T')) @@ -465,6 +625,15 @@ void AddModifierItem(string label, User32.KeyModifiers modValue) }; tsmiHotkeySettings.DropDownItems.Add(tsmiCaptureKey); + return tsmiHotkeySettings; + } + + /// + /// 建立右鍵選單的「進階設定」子選單,並組裝視窗、回饋、遊戲控制器與資料夾等項目 + /// + /// 進階設定子選單項目。 + private ToolStripMenuItem CreateSettingsMenu() + { // 進階設定子選單。 // 匯整視窗、回饋、遊戲控制器與資料夾等進階功能入口。 ToolStripMenuItem tsmiSettings = new(ControlExtensions.GetMnemonicText(Strings.Menu_Settings, 'S')) @@ -486,6 +655,131 @@ void AddModifierItem(string label, User32.KeyModifiers modValue) } }; + tsmiSettings.DropDownItems.Add(CreateWindowOperationsMenu()); + tsmiSettings.DropDownItems.Add(CreateFeedbackMenu()); + tsmiSettings.DropDownItems.Add(CreateGamepadSettingsMenu()); + tsmiSettings.DropDownItems.Add(new ToolStripSeparator()); + + // 歷程容量(需重啟)。 + // 控制記憶體中保留的輸入歷程筆數上限。 + ToolStripMenuItem tsmiCap = new(string.Empty) + { + AccessibleName = Strings.Settings_HistoryCapacity, + Tag = new MenuMetadata(Strings.Settings_HistoryCapacity, 'H', 1, 1000) + }; + tsmiCap.Click += (s, e) => + { + try + { + int? val = AskForValue(Strings.Settings_HistoryCapacity, AppSettings.Current.HistoryCapacity, 100, 1, 1000); + + if (val.HasValue && + val != AppSettings.Current.HistoryCapacity) + { + AppSettings.Current.HistoryCapacity = val.Value; + AppSettings.Save(); + + RefreshMenu(); + + AskForRestart(); + } + } + catch (Exception ex) + { + LoggerService.LogException(ex, "歷程容量設定失敗"); + + Debug.WriteLine($"[選單] tsmiCap.Click 失敗:{ex.Message}"); + } + }; + tsmiSettings.DropDownItems.Add(tsmiCap); + + tsmiSettings.DropDownItems.Add(new ToolStripSeparator()); + + // 開啟資料夾。 + // 開啟應用程式設定與資料檔所在的資料夾。 + ToolStripMenuItem tsmiOpenDataFolder = new(ControlExtensions.GetMnemonicText(Strings.Menu_OpenDataFolder, 'O')) + { + AccessibleName = Strings.Menu_OpenDataFolder, + AccessibleDescription = Strings.Menu_OpenDataFolder_Desc + }; + tsmiOpenDataFolder.Click += (s, e) => + { + try + { + if (Directory.Exists(AppSettings.ConfigDirectory)) + { + Process.Start(new ProcessStartInfo(AppSettings.ConfigDirectory) + { + UseShellExecute = true + }); + } + else + { + AnnounceA11y(Strings.Msg_FolderNotFound); + + GamepadMessageBox.Show( + this, + Strings.Msg_FolderNotFound, + Strings.Wrn_Title, + MessageBoxButtons.OK, + MessageBoxIcon.Warning, + gamepad: _gamepadController); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[選單] tsmiOpenDataFolder.Click 失敗:{ex.Message}"); + } + }; + tsmiSettings.DropDownItems.Add(tsmiOpenDataFolder); + + // 開啟日誌資料夾。 + // 開啟本機例外與診斷紀錄所在的日誌目錄。 + ToolStripMenuItem tsmiOpenLogFolder = new(ControlExtensions.GetMnemonicText(Strings.Menu_OpenLogFolder, 'L')) + { + AccessibleName = Strings.Menu_OpenLogFolder, + AccessibleDescription = Strings.Menu_OpenLogFolder_Desc + }; + tsmiOpenLogFolder.Click += (s, e) => + { + try + { + if (Directory.Exists(LoggerService.LogDirectory)) + { + Process.Start(new ProcessStartInfo(LoggerService.LogDirectory) + { + UseShellExecute = true + }); + } + else + { + AnnounceA11y(Strings.Msg_FolderNotFound); + + GamepadMessageBox.Show( + this, + Strings.Msg_FolderNotFound, + Strings.Wrn_Title, + MessageBoxButtons.OK, + MessageBoxIcon.Warning, + gamepad: _gamepadController); + } + } + catch (Exception ex) + { + Debug.WriteLine($"[選單] tsmiOpenLogFolder.Click 失敗:{ex.Message}"); + } + }; + tsmiSettings.DropDownItems.Add(tsmiOpenLogFolder); + + return tsmiSettings; + } + + /// + /// 建立「進階設定」中的「視窗與操作」子選單 + /// + /// 視窗與操作子選單項目。 + private ToolStripMenuItem CreateWindowOperationsMenu() + { // 視窗與操作。 // 包含視窗還原、剪貼簿重試與切換緩衝等系統互動參數。 ToolStripMenuItem tsmiWinOps = new(ControlExtensions.GetMnemonicText(Strings.Menu_Settings_Window, 'W')) @@ -615,58 +909,6 @@ void AddModifierItem(string label, User32.KeyModifiers modValue) tsmiWinOps.DropDownItems.Add(new ToolStripSeparator()); - // 新增數值設定選單項目。 - // parent: 父選單項目。label: 標籤文字。mnemonic: 助記鍵字母。 - // getter: 取得目前值。setter: 設定新值。defValue: 預設值。min/max: 值域。 - // a11yHint: 選填的無障礙播報補充說明。 - void AddNumericItem( - ToolStripMenuItem parent, - string label, - char mnemonic, - Func getter, - Action setter, - int defValue, - int min, - int max, - string? a11yHint = null) - { - ToolStripMenuItem item = new(string.Empty) - { - AccessibleName = label, - // 將範圍資訊與補充說明封裝至 Metadata,支援動態 A11y 描述生成。 - Tag = new MenuMetadata(label, mnemonic, min, max, a11yHint), - }; - - item.Click += (s, e) => - { - try - { - int? val = AskForValue(label, getter(), defValue, min, max); - - if (val.HasValue) - { - setter(val.Value); - - AppSettings.Save(); - - RefreshMenu(); - } - - // 焦點還原。 - // 當數值輸入對話框關閉後,將焦點精確還原至原選單項,最佳化螢幕閱讀器導覽流暢度。 - _lastFocusedMenuItem?.Select(); - } - catch (Exception ex) - { - LoggerService.LogException(ex, $"數值設定 [{label}] 失敗"); - - Debug.WriteLine($"[選單] {label} 設定失敗:{ex.Message}"); - } - }; - - parent.DropDownItems.Add(item); - } - AddNumericItem( tsmiWinOps, Strings.Settings_WindowRestoreDelay, @@ -728,8 +970,75 @@ void AddNumericItem( }; tsmiWinOps.DropDownItems.Add(tsmiResetWinOps); - tsmiSettings.DropDownItems.Add(tsmiWinOps); + return tsmiWinOps; + } + + /// + /// 新增數值設定選單項目;點選後以數值輸入對話框調整並立即儲存 + /// + /// 父選單項目。 + /// 標籤文字。 + /// 助記鍵字母。 + /// 取得目前值。 + /// 設定新值。 + /// 預設值。 + /// 最小值。 + /// 最大值。 + /// 選填的無障礙播報補充說明。 + private void AddNumericItem( + ToolStripMenuItem parent, + string label, + char mnemonic, + Func getter, + Action setter, + int defValue, + int min, + int max, + string? a11yHint = null) + { + ToolStripMenuItem item = new(string.Empty) + { + AccessibleName = label, + // 將範圍資訊與補充說明封裝至 Metadata,支援動態 A11y 描述生成。 + Tag = new MenuMetadata(label, mnemonic, min, max, a11yHint), + }; + + item.Click += (s, e) => + { + try + { + int? val = AskForValue(label, getter(), defValue, min, max); + + if (val.HasValue) + { + setter(val.Value); + + AppSettings.Save(); + + RefreshMenu(); + } + // 焦點還原。 + // 當數值輸入對話框關閉後,將焦點精確還原至原選單項,最佳化螢幕閱讀器導覽流暢度。 + _lastFocusedMenuItem?.Select(); + } + catch (Exception ex) + { + LoggerService.LogException(ex, $"數值設定 [{label}] 失敗"); + + Debug.WriteLine($"[選單] {label} 設定失敗:{ex.Message}"); + } + }; + + parent.DropDownItems.Add(item); + } + + /// + /// 建立「進階設定」中的「回饋」子選單 + /// + /// 回饋子選單項目。 + private ToolStripMenuItem CreateFeedbackMenu() + { // 回饋。 // 集中管理震動開關與強度等回饋設定。 ToolStripMenuItem tsmiFeedback = new(ControlExtensions.GetMnemonicText(Strings.Menu_Settings_Feedback, 'F')) @@ -874,8 +1183,15 @@ void AddNumericItem( }; tsmiFeedback.DropDownItems.Add(tsmiResetFeedback); - tsmiSettings.DropDownItems.Add(tsmiFeedback); + return tsmiFeedback; + } + /// + /// 建立「進階設定」中的「遊戲控制器」子選單 + /// + /// 遊戲控制器子選單項目。 + private ToolStripMenuItem CreateGamepadSettingsMenu() + { // 控制器。 // 提供遊戲控制器輸入 API、死區、重複輸入與校正狀態重設等設定。 ToolStripMenuItem tsmiGamepad = new(ControlExtensions.GetMnemonicText(Strings.Menu_Settings_Gamepad, 'G')) @@ -1234,206 +1550,15 @@ void AddFaceLayoutItem(AppSettings.GamepadFaceButtonMode mode) }; tsmiGamepad.DropDownItems.Add(tsmiResetGamepad); - tsmiSettings.DropDownItems.Add(tsmiGamepad); - tsmiSettings.DropDownItems.Add(new ToolStripSeparator()); - - // 歷程容量(需重啟)。 - // 控制記憶體中保留的輸入歷程筆數上限。 - ToolStripMenuItem tsmiCap = new(string.Empty) - { - AccessibleName = Strings.Settings_HistoryCapacity, - Tag = new MenuMetadata(Strings.Settings_HistoryCapacity, 'H', 1, 1000) - }; - tsmiCap.Click += (s, e) => - { - try - { - int? val = AskForValue(Strings.Settings_HistoryCapacity, AppSettings.Current.HistoryCapacity, 100, 1, 1000); - - if (val.HasValue && - val != AppSettings.Current.HistoryCapacity) - { - AppSettings.Current.HistoryCapacity = val.Value; - AppSettings.Save(); - - RefreshMenu(); - - AskForRestart(); - } - } - catch (Exception ex) - { - LoggerService.LogException(ex, "歷程容量設定失敗"); - - Debug.WriteLine($"[選單] tsmiCap.Click 失敗:{ex.Message}"); - } - }; - tsmiSettings.DropDownItems.Add(tsmiCap); - - tsmiSettings.DropDownItems.Add(new ToolStripSeparator()); - - // 開啟資料夾。 - // 開啟應用程式設定與資料檔所在的資料夾。 - ToolStripMenuItem tsmiOpenDataFolder = new(ControlExtensions.GetMnemonicText(Strings.Menu_OpenDataFolder, 'O')) - { - AccessibleName = Strings.Menu_OpenDataFolder, - AccessibleDescription = Strings.Menu_OpenDataFolder_Desc - }; - tsmiOpenDataFolder.Click += (s, e) => - { - try - { - if (Directory.Exists(AppSettings.ConfigDirectory)) - { - Process.Start(new ProcessStartInfo(AppSettings.ConfigDirectory) - { - UseShellExecute = true - }); - } - else - { - AnnounceA11y(Strings.Msg_FolderNotFound); - - GamepadMessageBox.Show( - this, - Strings.Msg_FolderNotFound, - Strings.Wrn_Title, - MessageBoxButtons.OK, - MessageBoxIcon.Warning, - gamepad: _gamepadController); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[選單] tsmiOpenDataFolder.Click 失敗:{ex.Message}"); - } - }; - tsmiSettings.DropDownItems.Add(tsmiOpenDataFolder); - - // 開啟日誌資料夾。 - // 開啟本機例外與診斷紀錄所在的日誌目錄。 - ToolStripMenuItem tsmiOpenLogFolder = new(ControlExtensions.GetMnemonicText(Strings.Menu_OpenLogFolder, 'L')) - { - AccessibleName = Strings.Menu_OpenLogFolder, - AccessibleDescription = Strings.Menu_OpenLogFolder_Desc - }; - tsmiOpenLogFolder.Click += (s, e) => - { - try - { - if (Directory.Exists(LoggerService.LogDirectory)) - { - Process.Start(new ProcessStartInfo(LoggerService.LogDirectory) - { - UseShellExecute = true - }); - } - else - { - AnnounceA11y(Strings.Msg_FolderNotFound); - - GamepadMessageBox.Show( - this, - Strings.Msg_FolderNotFound, - Strings.Wrn_Title, - MessageBoxButtons.OK, - MessageBoxIcon.Warning, - gamepad: _gamepadController); - } - } - catch (Exception ex) - { - Debug.WriteLine($"[選單] tsmiOpenLogFolder.Click 失敗:{ex.Message}"); - } - }; - tsmiSettings.DropDownItems.Add(tsmiOpenLogFolder); - - // 清除歷程。 - // 清空目前只保存在記憶體中的輸入歷程資料。 - ToolStripMenuItem tsmiClearHistory = new(ControlExtensions.GetMnemonicText(Strings.Menu_ClearHistory, 'C')) - { - Name = "TsmiClearHistory", - AccessibleName = Strings.Menu_ClearHistory, - AccessibleDescription = Strings.Menu_ClearHistory_Desc - }; - tsmiClearHistory.Click += (s, e) => - { - try - { - _historyService?.Clear(); - - // 清除後主動將焦點拉回輸入框,確保使用者能直接開始輸入。 - TBInput.Focus(); - - FeedbackService.PlaySound(SystemSounds.Asterisk); - - AnnounceA11y(Strings.Msg_InputCleared); - } - catch (Exception ex) - { - Debug.WriteLine($"[選單] tsmiClearHistory.Click 失敗:{ex.Message}"); - } - }; - - // 離開。 - // 關閉主視窗並結束整個應用程式流程。 - ToolStripMenuItem tsmiExit = new(ControlExtensions.GetMnemonicText(Strings.Menu_Exit, 'X')) - { - AccessibleName = Strings.Menu_Exit, - AccessibleDescription = Strings.A11y_Menu_Exit_Desc - }; - tsmiExit.Click += (s, e) => - { - try - { - Close(); - } - catch (Exception ex) - { - Debug.WriteLine($"[選單] tsmiExit.Click 失敗:{ex.Message}"); - } - }; - - // 說明(WCAG 3.3.5)。 - // 顯示鍵盤與遊戲控制器操作對照的說明對話框。 - ToolStripMenuItem tsmiHelp = new(ControlExtensions.GetMnemonicText(Strings.Menu_Help, 'H')) - { - AccessibleName = Strings.Menu_Help, - AccessibleDescription = Strings.Menu_Help_Desc - }; - tsmiHelp.Click += (s, e) => - { - try - { - ShowHelpDialog(); - } - catch (Exception ex) - { - Debug.WriteLine($"[選單] tsmiHelp.Click 失敗:{ex.Message}"); - } - }; - - // 使用共享快取取得選單字型。 - _cmsInput.Font = GetSharedA11yFont(DeviceDpi); - _cmsInput.Opened += (s, e) => EnsureContextMenuReadyForKeyboard(_cmsInput); - _cmsInput.PreviewKeyDown += ContextMenu_PreviewKeyDown; - _cmsInput.KeyDown += ContextMenu_KeyDown; - _cmsInput.Closed += (s, e) => RestorePhraseSubMenuAutoClose(); - _cmsInput.Closing += (s, e) => - { - try - { - if (ShouldSuppressPhraseMenuClose(e.CloseReason)) - { - e.Cancel = true; - } - } - catch (Exception ex) - { - Debug.WriteLine($"[選單] _cmsInput.Closing 失敗:{ex.Message}"); - } - }; + return tsmiGamepad; + } + /// + /// 建立右鍵選單的「片語」子選單;項目會在每次展開前依目前片語重建 + /// + [MemberNotNull(nameof(_tsmiPhrases))] + private void InitializePhrasesMenu() + { // 片語子選單。 _tsmiPhrases = new ToolStripMenuItem(ControlExtensions.GetMnemonicText(Strings.Menu_Phrases, 'F')) { @@ -1501,54 +1626,6 @@ void AddFaceLayoutItem(AppSettings.GamepadFaceButtonMode mode) Debug.WriteLine($"[選單] _tsmiPhrases.DropDown.Closing 失敗:{ex.Message}"); } }; - - 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); - _cmsInput.Items.Add(_tsmiMinimizeOnReturn); - _cmsInput.Items.Add(new ToolStripSeparator()); - _cmsInput.Items.Add(_tsmiPhrases); - _cmsInput.Items.Add(new ToolStripSeparator()); - _cmsInput.Items.Add(tsmiHotkeySettings); - _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()); - _cmsInput.Items.Add(tsmiExit); - - // 綁定選單至容器控制項,確保 TBInput 能保留其原始的 Windows 右鍵選單(剪下、複製、貼上)。 - PInputHost.ContextMenuStrip = _cmsInput; - TLPHost.ContextMenuStrip = _cmsInput; } /// diff --git a/src/InputBox/MainForm.Events.cs b/src/InputBox/MainForm.Events.cs index 49073f7..fab9a09 100644 --- a/src/InputBox/MainForm.Events.cs +++ b/src/InputBox/MainForm.Events.cs @@ -118,7 +118,7 @@ await Task.Delay( else { // 強制還原視窗,避免在 Windows 桌面(平板模式)下自動最大化。 - // 對「Windows 遊戲:全螢幕體驗」(Xbox 全螢幕體驗)不受影響,一樣會自動最大化。 + // 對「Xbox 模式」不受影響,一樣會自動最大化。 User32.ShowWindow(Handle, User32.ShowWindowCommand.Restore); // Restore 後執行位置修正,確保 WINDOWPLACEMENT.rcNormalPosition 夾回工作區範圍內。 @@ -1533,12 +1533,8 @@ private async Task FlashAlertAsync() try { - // 決定警示色。 - // 修正選色邏輯以對齊反轉後的控制項背景。 - // 淺色模式(黑底):用 DarkOrange(8.3:1);深色模式(白底):用 Firebrick(5.8:1)。 - Color alertColor = SystemInformation.HighContrast ? - SystemColors.Highlight : - (TBInput.IsDarkModeActive() ? Color.Firebrick : Color.DarkOrange); + bool isDark = TBInput.IsDarkModeActive(); + Color alertColor = FlashAlertAnimator.GetAlertColor(isDark, SystemInformation.HighContrast); void ApplyAlertVisuals(float intensity) { @@ -1548,92 +1544,17 @@ void ApplyAlertVisuals(float intensity) return; } - bool isDark = TBInput.IsDarkModeActive(); + (Color back, Color fore) = FlashAlertAnimator.ComputeFrameColors( + intensity, + isDark, + alertColor, + SystemInformation.HighContrast); - if (SystemInformation.HighContrast) - { - bool isAlert = intensity > 0.5f; - - Color hcBack = isAlert ? - alertColor : - SystemColors.Window, - hcFore = isAlert ? - SystemColors.HighlightText : - SystemColors.WindowText; - - // 同步更新背景與前景,確保高對比下文字可讀性。 - PInputHost.UpdateRecursive(hcBack, hcFore); - } - else - { - // 閃爍基色改為純淨底色(黑/白),避免與高飽和焦點色(Cyan/RoyalBlue)插值產生髒濁色。 - Color pureBase = isDark ? - Color.White : - Color.Black; - - int rB = (int)(pureBase.R + (alertColor.R - pureBase.R) * intensity), - gB = (int)(pureBase.G + (alertColor.G - pureBase.G) * intensity), - bB = (int)(pureBase.B + (alertColor.B - pureBase.B) * intensity); - - Color flashColor = Color.FromArgb(255, rB, gB, bB); - - // WCAG 相對亮度精確切換閾值(crossover L≈0.1791),修復 YUV≈128 近似在切換帶(intensity≈0.75) - // 導致文字對比跌破 AA(3.5~4.2:1)的問題。修復後全程 ≥4.64:1 AA; - // 14f bold 大型文字全程 ≥4.5:1 AAA。 - static float FLin(int c) - { - float f = c / 255f; - - return f <= 0.04045f ? f / 12.92f : MathF.Pow((f + 0.055f) / 1.055f, 2.4f); - } - - Color flashFore = (0.2126f * FLin(flashColor.R) + 0.7152f * FLin(flashColor.G) + 0.0722f * FLin(flashColor.B)) > 0.1791f ? - Color.Black : - Color.White; - - // 遞歸背景與前景同步:僅作用於數據內容區域(PInputHost),按鈕保持其靜態視覺狀態。 - PInputHost.UpdateRecursive(flashColor, flashFore); - } + // 僅作用於數據內容區域(PInputHost),按鈕保持其靜態視覺狀態。 + PInputHost.UpdateRecursive(back, fore); } - // 嚴格遵守光敏性癲癇防護與使用者偏好: - // 若使用者在系統層級關閉了動畫效果(UIEffectsEnabled 為 false), - // 則不進行循環閃爍,改為一次性的「長脈衝(Static Pulse)」回饋。 - if (!SystemInformation.UIEffectsEnabled || - !AppSettings.Current.EnableAnimatedVisualAlerts) - { - await this.SafeInvokeAsync(() => ApplyAlertVisuals(1.0f)); - - // 維持一段較長時間(800ms)讓低視能使用者感知狀態,隨後恢復。 - await Task.Delay(800, token); - - return; - } - - int totalDuration = AppSettings.PhotoSafeFrequencyMs; - - using PeriodicTimer timer = new(TimeSpan.FromMilliseconds(AppSettings.TargetFrameTimeMs)); - - long startTime = Stopwatch.GetTimestamp(); - - while (await timer.WaitForNextTickAsync(token)) - { - long elapsedTicks = Stopwatch.GetTimestamp() - startTime; - - double elapsedMs = (double)elapsedTicks / Stopwatch.Frequency * 1000.0; - - if (elapsedMs >= totalDuration) - { - break; - } - - // 使用 AppSettings.PhotoSafeFrequencyMs 定義的正弦波週期(1Hz)。 - double angle = elapsedMs / AppSettings.PhotoSafeFrequencyMs * 2.0 * Math.PI - (Math.PI / 2.0); - - float intensity = (float)((Math.Sin(angle) + 1.0) / 2.0); - - await this.SafeInvokeAsync(() => ApplyAlertVisuals(intensity)); - } + await FlashAlertAnimator.RunAsync(this, ApplyAlertVisuals, token); } catch (OperationCanceledException) { diff --git a/src/InputBox/MainForm.Gamepad.cs b/src/InputBox/MainForm.Gamepad.cs index 8a03f16..7fdfaa6 100644 --- a/src/InputBox/MainForm.Gamepad.cs +++ b/src/InputBox/MainForm.Gamepad.cs @@ -2,7 +2,6 @@ using InputBox.Core.Extensions; using InputBox.Core.Feedback; using InputBox.Core.Input; -using InputBox.Core.Interop; using InputBox.Core.Services; using InputBox.Core.Utilities; using InputBox.Resources; @@ -1630,38 +1629,6 @@ private void PlaySelectionFeedback(int direction, bool wordGranularity) FeedbackService.PlaySelectionCue(wordGranularity, burstLevel); } - /// - /// 以現有的單字跳轉邏輯推算右搖桿在單字粒度下的選取目標位置。 - /// - /// 目前游標位置(字元索引)。 - /// 跳轉方向;正值為向右,負值為向左。 - /// 跳轉後的游標位置。 - private int GetWordSelectionCaretTarget(int caret, int direction) - { - if (TBInput == null || - TBInput.IsDisposed) - { - return caret; - } - - int originalStart = TBInput.SelectionStart; - int originalLength = TBInput.SelectionLength; - - try - { - TBInput.SelectionStart = Math.Clamp(caret, 0, TBInput.TextLength); - TBInput.SelectionLength = 0; - TBInput.WordJump(direction > 0); - - return TBInput.SelectionStart; - } - finally - { - TBInput.SelectionStart = originalStart; - TBInput.SelectionLength = originalLength; - } - } - /// /// 取得資源字串;若缺少翻譯則回退到預設文字。 /// @@ -2543,23 +2510,11 @@ private void ExpandSelection(int direction) return; } - // 當目前沒有選取範圍,或是目前的選取範圍與我們的錨點不匹配時,重新設定錨點。 + // 當目前沒有選取範圍,或是目前的選取範圍與我們的錨點不匹配時,重新設定錨點,並推算目前的活動邊緣(Caret)。 // 這能確保手動點擊或鍵盤選取後,RS 選取能從正確的位置開始。 - if (TBInput.SelectionLength == 0 || - _rsSelectionAnchor == null || - (TBInput.SelectionStart != _rsSelectionAnchor.Value && - TBInput.SelectionStart + TBInput.SelectionLength != _rsSelectionAnchor.Value)) - { - _rsSelectionAnchor = TBInput.SelectionStart; - } - - int anchor = _rsSelectionAnchor.Value; + (int anchor, int caret) = TBInput.ResolveSelectionAnchor(_rsSelectionAnchor); - // 推算目前的活動邊緣(Caret)。 - // WinForms SelectionStart 始終為較小的索引,因此若 Start 與錨點一致,則 Caret 在右側;否則 Caret 在左側。 - int caret = (TBInput.SelectionStart == anchor) ? - (anchor + TBInput.SelectionLength) : - TBInput.SelectionStart; + _rsSelectionAnchor = anchor; // 防禦性寫法:確保方向永遠只會是 -1、0 或 1,杜絕任何溢出造成的邏輯錯亂。 int safeDirection = Math.Sign(direction); @@ -2572,7 +2527,7 @@ private void ExpandSelection(int direction) } int newCaret = wordGranularity ? - GetWordSelectionCaretTarget(caret, safeDirection) : + TBInput.GetWordJumpTarget(caret, safeDirection > 0) : Math.Clamp(caret + safeDirection, 0, TBInput.TextLength); if (newCaret == caret) @@ -2582,13 +2537,8 @@ private void ExpandSelection(int direction) return; } - // 使用 Win32 EM_SETSEL 設定選取範圍。 - // wParam 為錨點,lParam 為活動邊緣。這能確保視覺上的游標(Caret)正確跟隨活動邊緣,並支援反向縮減。 - User32.SendMessage(TBInput.Handle, (uint)User32.WindowMessage.EM_SETSEL, anchor, newCaret); - - // 確保活動邊緣(Caret)保持在可視範圍內,避免選取延伸到畫面外時 - // Windows TextBox 在可視邊界處繪製藍色底線 artifact。 - TBInput.ScrollToCaret(); + // 以錨點與活動邊緣設定選取範圍,讓視覺游標跟隨活動邊緣並支援反向縮減,並保持活動邊緣可見。 + TBInput.SetSelectionWithActiveEdge(anchor, newCaret); // A11y:報讀目前選取的文字內容。 if (TBInput.SelectionLength > 0) diff --git a/src/InputBox/MainForm.cs b/src/InputBox/MainForm.cs index 75893c1..108c4df 100644 --- a/src/InputBox/MainForm.cs +++ b/src/InputBox/MainForm.cs @@ -1223,26 +1223,64 @@ private void RestartApplication() // 為下一個重啟後的執行個體建立一次性前景啟用請求,降低焦點跳回前一個視窗的機率。 RestartActivationCoordinator.Shared.RequestActivationOnNextLaunch(); - // 由目前前景執行個體主動授權新的重啟程序可呼叫 SetForegroundWindow, - // 提升 Hosted Runner 與桌面自動化環境下的前景恢復成功率。 - _ = User32.AllowSetForegroundWindow(User32.AllowSetForegroundWindowAnyProcess); - // 在正式結束前同步停止所有控制器震動,防止程序關閉後馬達持續空轉。 FeedbackService.EmergencyStopAllActiveControllers(); - Program.ReleaseMutex(); + List mainForms = [.. Application.OpenForms.OfType()]; - // 安全地關閉所有 MainForm 實例,確保它們的 Dispose 與 FormClosing 被正確觸發, - // 從而釋放全域的 SystemEvents 鉤子,防止重啟時發生靜態資源洩漏。 - foreach (Form form in Application.OpenForms.Cast
().ToList()) + // 新執行個體會在舊視窗關閉前啟動,因此先解除全域快速鍵, + // 避免新執行個體接手後註冊同一組快速鍵失敗。 + foreach (MainForm mainForm in mainForms) { - if (form is MainForm mainForm) + if (mainForm.IsHandleCreated) { - mainForm.Close(); + GlobalHotKeyService.UnregisterShowInputHotkey(mainForm.Handle); } } - Application.Restart(); + // AllowSetForegroundWindow 只有在呼叫端仍為前景程序(或收到最後一次輸入)時才有效; + // 以控制器觸發重啟不會產生 Windows 輸入事件,因此必須趁目前視窗仍在前景時, + // 先啟動新執行個體,再只授權該程序呼叫 SetForegroundWindow,而不開放給所有程序。 + // 新執行個體帶有交接參數,會等待本實例釋放單一執行個體 Mutex 後才接手, + // 因此不在啟動前釋放 Mutex,避免另外啟動的執行個體在空窗期搶先取得。 + bool launched = RestartProcessLauncher.TryStart( + RestartProcessLauncher.CreateStartInfo( + Application.ExecutablePath, + Environment.GetCommandLineArgs(), + handoffProcessId: Environment.ProcessId), + out int newProcessId); + + _ = User32.AllowSetForegroundWindow( + launched ? + newProcessId : + User32.AllowSetForegroundWindowAnyProcess); + + if (!launched) + { + // 退回 WinForms 內建的重啟流程時,新執行個體不帶交接參數,必須先釋放 Mutex 才能正常啟動。 + Program.ReleaseMutex(); + } + + // 安全地關閉所有 MainForm 實例,確保它們的 Dispose 與 FormClosing 被正確觸發, + // 從而釋放全域的 SystemEvents 鉤子,防止重啟時發生靜態資源洩漏。 + foreach (MainForm mainForm in mainForms) + { + mainForm.Close(); + } + + if (launched) + { + Application.Exit(); + + // 所有視窗已關閉,在同一個(持有 Mutex 的)主執行緒上釋放 Mutex,讓等待中的新執行個體接手。 + // Environment.Exit 不會執行 Main 的 finally 清理,因此必須在此明確釋放。 + Program.ReleaseMutex(); + } + else + { + // 無法自行啟動新執行個體時,退回 WinForms 內建的重啟流程。 + Application.Restart(); + } Environment.Exit(0); } diff --git a/src/InputBox/Program.cs b/src/InputBox/Program.cs index bafe847..b48b373 100644 --- a/src/InputBox/Program.cs +++ b/src/InputBox/Program.cs @@ -64,9 +64,32 @@ static void Main() name: @"Local\InputBox_40A57F4D-4C7E-45FD-9DC7-BE96DC026D66_SingleInstance", out bool createdNew); + bool isHandoffLaunch = SingleInstanceHandoff.TryGetHandoffProcessId( + Environment.GetCommandLineArgs(), + out int handoffProcessId); + + SingleInstanceStartupAction startupAction = SingleInstanceHandoff.ResolveStartupAction(createdNew, isHandoffLaunch); + + if (startupAction == SingleInstanceStartupAction.WaitForHandoff) + { + // 程式內重啟的接手者:舊執行個體會在關閉所有視窗後才釋放 Mutex, + // 在此等待並接手,而不是把 Mutex 視為「已有實例」而喚醒後退出。 + if (TryAcquireMutexForHandoff(handoffProcessId)) + { + createdNew = true; + startupAction = SingleInstanceStartupAction.Proceed; + } + else + { + LoggerService.LogWarning($"SingleInstance.HandoffTimeout pid={Environment.ProcessId} sourcePid={handoffProcessId} timeoutMs={(int)SingleInstanceHandoff.HandoffWaitTimeout.TotalMilliseconds}"); + + startupAction = SingleInstanceStartupAction.ActivateExisting; + } + } + Interlocked.Exchange(ref _ownsMutex, createdNew ? 1 : 0); - if (!createdNew) + if (startupAction == SingleInstanceStartupAction.ActivateExisting) { LoggerService.LogInfo($"SingleInstance.MutexCheck pid={Environment.ProcessId} createdNew={createdNew}"); @@ -101,15 +124,28 @@ static void Main() return; } + SingleInstanceFallbackDecision fallbackDecision = SingleInstanceHandoff.ResolveFallback( + fallbackPermitted, + RestartActivationCoordinator.Shared.HasPendingActivationRequest()); + // 收斂策略:若已找到可喚醒視窗但前景切換被系統阻擋, // 則不允許 fallback 啟動新視窗,避免破壞單實例預期。 - if (!fallbackPermitted) + if (fallbackDecision == SingleInstanceFallbackDecision.ExitForegroundBlocked) { LoggerService.LogWarning($"SingleInstance.FallbackSuppressed pid={Environment.ProcessId} reason=foreground_blocked detail={activationDiagnostic}"); return; } + // 程式內重啟交接進行中:舊實例的視窗已關閉、接手的新實例尚未顯示視窗, + // 此時若 fallback 啟動會多開一個視窗,由接手的新實例負責顯示即可。 + if (fallbackDecision == SingleInstanceFallbackDecision.ExitRestartHandoffPending) + { + LoggerService.LogInfo($"SingleInstance.FallbackSuppressed pid={Environment.ProcessId} reason=restart_handoff_pending detail={activationDiagnostic}"); + + return; + } + // Fallback 次數防護:確保同一時間只有一個 fallback 實例能繼續啟動。 // 若另一個 fallback 進程正在執行中(視窗尚未出現),則靜默中止本次啟動, // 防止快速多次點擊造成多個視窗同時開啟。 @@ -189,6 +225,45 @@ static void Main() } } + /// + /// 程式內重啟的接手者等待舊執行個體釋放單一執行個體 Mutex 並取得所有權 + /// + /// + /// 必須在建立 Mutex 的主執行緒上呼叫,Mutex 的所有權才會屬於之後負責釋放它的同一個執行緒。 + /// 舊執行個體若未釋放就結束,Mutex 會成為被遺棄狀態,仍視為成功接手。 + /// + /// 舊執行個體的程序識別碼(僅供診斷記錄)。 + /// 若在逾時前取得 Mutex 則回傳 true。 + private static bool TryAcquireMutexForHandoff(int sourceProcessId) + { + Mutex? mutex = _mutex; + + if (mutex == null) + { + return false; + } + + long startTimestamp = Stopwatch.GetTimestamp(); + bool acquired; + + try + { + acquired = mutex.WaitOne(SingleInstanceHandoff.HandoffWaitTimeout); + } + catch (AbandonedMutexException) + { + // 舊執行個體未釋放即結束,所有權已轉移給本執行緒。 + acquired = true; + } + + if (acquired) + { + LoggerService.LogInfo($"SingleInstance.HandoffAcquired pid={Environment.ProcessId} sourcePid={sourceProcessId} waitMs={(int)Stopwatch.GetElapsedTime(startTimestamp).TotalMilliseconds}"); + } + + return acquired; + } + /// /// 系統結束處理常式 /// diff --git a/tests/InputBox.Tests/FlashAlertAnimatorTests.cs b/tests/InputBox.Tests/FlashAlertAnimatorTests.cs new file mode 100644 index 0000000..63f56ed --- /dev/null +++ b/tests/InputBox.Tests/FlashAlertAnimatorTests.cs @@ -0,0 +1,90 @@ +using InputBox.Core.Feedback; +using Xunit; + +namespace InputBox.Tests; + +/// +/// 驗證主視窗、數值輸入與片語編輯共用的閃爍警示配色與動畫強度計算。 +/// +public sealed class FlashAlertAnimatorTests +{ + /// + /// 一般模式下,深色與淺色主題應分別使用 Firebrick 與 DarkOrange,高對比模式使用系統醒目提示色。 + /// + [Fact] + public void GetAlertColor_ReturnsThemeSpecificColor() + { + Assert.Equal(Color.Firebrick, FlashAlertAnimator.GetAlertColor(isDark: true, highContrast: false)); + Assert.Equal(Color.DarkOrange, FlashAlertAnimator.GetAlertColor(isDark: false, highContrast: false)); + Assert.Equal(SystemColors.Highlight, FlashAlertAnimator.GetAlertColor(isDark: true, highContrast: true)); + } + + /// + /// 強度為 0 時背景應為純淨底色(深色用白、淺色用黑),強度為 1 時背景應等於警示色。 + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ComputeFrameColors_InterpolatesFromPureBaseToAlertColor(bool isDark) + { + Color alert = FlashAlertAnimator.GetAlertColor(isDark, highContrast: false); + + (Color start, _) = FlashAlertAnimator.ComputeFrameColors(0f, isDark, alert, highContrast: false); + (Color end, _) = FlashAlertAnimator.ComputeFrameColors(1f, isDark, alert, highContrast: false); + + Assert.Equal((isDark ? Color.White : Color.Black).ToArgb(), start.ToArgb()); + Assert.Equal(alert.ToArgb(), end.ToArgb()); + } + + /// + /// 整個閃爍過程中,每一幀的文字與背景對比都必須維持 WCAG AA(≥4.5:1),避免在切換帶跌破可讀門檻。 + /// + [Theory] + [InlineData(true)] + [InlineData(false)] + public void ComputeFrameColors_KeepsTextContrastAtLeastAaThroughoutPulse(bool isDark) + { + Color alert = FlashAlertAnimator.GetAlertColor(isDark, highContrast: false); + + for (int step = 0; step <= 100; step++) + { + float intensity = step / 100f; + + (Color back, Color fore) = FlashAlertAnimator.ComputeFrameColors(intensity, isDark, alert, highContrast: false); + + float lighter = Math.Max(FlashAlertAnimator.GetRelativeLuminance(back), FlashAlertAnimator.GetRelativeLuminance(fore)); + float darker = Math.Min(FlashAlertAnimator.GetRelativeLuminance(back), FlashAlertAnimator.GetRelativeLuminance(fore)); + float contrast = (lighter + 0.05f) / (darker + 0.05f); + + Assert.True(contrast >= 4.5f, $"isDark={isDark} intensity={intensity} contrast={contrast:F2}"); + } + } + + /// + /// 高對比模式不插值,強度過半即切換為醒目提示配色,否則維持系統視窗配色。 + /// + [Fact] + public void ComputeFrameColors_HighContrast_SwitchesAtHalfIntensity() + { + Color alert = FlashAlertAnimator.GetAlertColor(isDark: false, highContrast: true); + + (Color lowBack, Color lowFore) = FlashAlertAnimator.ComputeFrameColors(0.4f, isDark: false, alert, highContrast: true); + (Color highBack, Color highFore) = FlashAlertAnimator.ComputeFrameColors(0.6f, isDark: false, alert, highContrast: true); + + Assert.Equal(SystemColors.Window, lowBack); + Assert.Equal(SystemColors.WindowText, lowFore); + Assert.Equal(alert, highBack); + Assert.Equal(SystemColors.HighlightText, highFore); + } + + /// + /// 正弦脈衝應從 0 開始、在週期中點達到最大值 1,並在週期結束時回到 0。 + /// + [Fact] + public void ComputeIntensity_ProducesSinglePulsePerPeriod() + { + Assert.Equal(0f, FlashAlertAnimator.ComputeIntensity(0, 1000), 0.0001f); + Assert.Equal(1f, FlashAlertAnimator.ComputeIntensity(500, 1000), 0.0001f); + Assert.Equal(0f, FlashAlertAnimator.ComputeIntensity(1000, 1000), 0.0001f); + } +} diff --git a/tests/InputBox.Tests/GamepadBiasSmoothingTests.cs b/tests/InputBox.Tests/GamepadBiasSmoothingTests.cs new file mode 100644 index 0000000..a5922c7 --- /dev/null +++ b/tests/InputBox.Tests/GamepadBiasSmoothingTests.cs @@ -0,0 +1,56 @@ +using InputBox.Core.Input; +using Xunit; + +namespace InputBox.Tests; + +/// +/// 驗證 XInput 與 GameInput 共用的自適應 EMA 學習率公式。 +/// +public sealed class GamepadBiasSmoothingTests +{ + /// + /// 誤差為 0 時應使用最低保守學習率,避免把有效輸入誤學成硬體偏移。 + /// + [Fact] + public void ComputeAdaptiveSmoothing_ZeroError_ReturnsBaseSmoothing() + { + float alpha = GamepadBiasSmoothing.ComputeAdaptiveSmoothing(0f, 0.05f, 0.03f, 0.15f); + + Assert.Equal(0.03f, alpha, 0.0001f); + } + + /// + /// 誤差達到或超過範圍時應使用最高學習率,且正負誤差結果相同。 + /// + [Theory] + [InlineData(0.05f)] + [InlineData(-0.05f)] + [InlineData(0.5f)] + public void ComputeAdaptiveSmoothing_ErrorAtOrBeyondRange_ReturnsMaxSmoothing(float error) + { + float alpha = GamepadBiasSmoothing.ComputeAdaptiveSmoothing(error, 0.05f, 0.03f, 0.15f); + + Assert.Equal(0.15f, alpha, 0.0001f); + } + + /// + /// 相同比例的誤差在 XInput(short 尺度)與 GameInput(浮點尺度)下必須得到相同學習率,確保兩個後端行為一致。 + /// + [Fact] + public void ComputeAdaptiveSmoothing_SameRelativeErrorAcrossScales_ReturnsSameAlpha() + { + float xinputAlpha = GamepadBiasSmoothing.ComputeAdaptiveSmoothing( + 819f, + 1638f, + GamepadBiasSmoothing.LeftStickBiasXBaseSmoothing, + GamepadBiasSmoothing.LeftStickBiasXMaxSmoothing); + float gameInputAlpha = GamepadBiasSmoothing.ComputeAdaptiveSmoothing( + 0.025f, + 0.05f, + GamepadBiasSmoothing.LeftStickBiasXBaseSmoothing, + GamepadBiasSmoothing.LeftStickBiasXMaxSmoothing); + + Assert.Equal(xinputAlpha, gameInputAlpha, 0.0001f); + Assert.Equal(0.09f, gameInputAlpha, 0.0001f); + } +} diff --git a/tests/InputBox.Tests/README.md b/tests/InputBox.Tests/README.md index e778274..7886ba1 100644 --- a/tests/InputBox.Tests/README.md +++ b/tests/InputBox.Tests/README.md @@ -23,6 +23,8 @@ | `GamepadControllerFactoryTests` | 控制器後端建立策略,驗證 XInput 預設路徑、GameInput 成功路徑,以及 GameInput 執行階段不可用時退避至 XInput | 3 | | `GamepadControllerPauseTests` | 控制器在 `Pause()` / `Resume()`、連線可用性語意、GameInput 讀取缺失斷線重列舉、裝置狀態過濾、震動停止安全性、`ClearAllEvents` 肩鍵釋放訂閱清除與原生對話框切換時的殘留輸入回歸保護 | 10 | | `GameInputDirectUsageTests` | `GameInputGamepadController` 直接使用 `InputWeave.GameInput` 遊戲控制器介面的守門、callback function pointer marshaling、官方 v3 按鍵位元、快照邊緣偵測、Release 日誌邊界、穩定裝置識別與震動參數保留 | 9 | +| `FlashAlertAnimatorTests` | `FlashAlertAnimator` 共用閃爍警示:主題警示色、純淨底色至警示色的插值、整個脈衝過程文字對比 ≥4.5:1、高對比模式半強度切換,以及 1Hz 單次正弦脈衝強度 | 7 | +| `GamepadBiasSmoothingTests` | `GamepadBiasSmoothing` 自適應 EMA 學習率公式:零誤差用基礎值、超出範圍用最大值,以及 XInput 與 GameInput 不同數值尺度下結果一致 | 5 | | `GamepadCalibrationVisualizerMapperTests` | `GamepadCalibrationVisualizerMapper` 對校準視覺化座標限制、死區半徑換算、D-Pad 導覽防誤觸,以及雙搖桿狀態/控制器連線文案格式化的回歸保護 | 14 | | `GamepadEventBinderTests` | `GamepadEventBinder` 的 LB / RB / LT / RT 與肩鍵放開事件綁定回歸保護 | 1 | | `GamepadFaceButtonProfileTests` | `GamepadFaceButtonProfile` 的 Auto 解析、手動覆寫優先權、GameInput 裝置識別保留 VID/PID 時的 Sony/Nintendo 判斷,以及 Xbox / PlayStation / Nintendo 模式的按鍵標示、助記詞同步、資源化字串、主畫面說明文字、目前生效配置顯示、標題列提示、選單勾選邏輯與 PlayStation ○/× 確認模式回歸保護 | 25 | @@ -38,14 +40,17 @@ | `LoggerServiceTests` | `LoggerService` 測試環境專屬日誌分流、Release-like 日誌門檻、環境變數覆寫與正式日誌隔離保護 | 8 | | `MainFormUiSmokeTests` | `MainForm` 使用 FlaUI 驗證主視窗啟動、右鍵選單主要命令、設定中的控制器子選單、控制器校準視覺化對話框、片語子選單、片語管理視窗、片語編輯視窗、HelpDialog、返回時最小化確認對話框、程式內確認重啟後主視窗保持前景,以及基本複製流程的 UI 冒煙測試 | 11 | | `PhraseServiceTests` | `PhraseService` CRUD、匯出/匯入、併發匯出、併發暫存檔誤刪(含 managed 暫存檔寬限期保留),以及持久化失敗時的記憶體復原回歸保護 | 40 | -| `RestartActivationCoordinatorTests` | `RestartActivationCoordinator` 的一次性重啟前景啟用標記、單次消費與過期清理保護 | 3 | +| `RestartActivationCoordinatorTests` | `RestartActivationCoordinator` 的一次性重啟前景啟用標記、單次消費與過期清理保護;以及查詢待處理請求不消費標記、無標記或過期時不判定為交接中 | 5 | | `RestartPromptStateTests` | 需重啟設定的待處理狀態追蹤、標題列提示,以及右鍵選單依 App 設定/系統變更/兩者同時存在而動態切換文案的回歸保護 | 7 | +| `RestartProcessLauncherTests` | `RestartProcessLauncher` 重啟啟動資訊:沿用目前執行檔、不透過殼層啟動、只轉送執行檔之後的引數並保留空白與引號(取代 `Application.Restart()` 以便只授權新程序前景的回歸保護);以及附加單一執行個體交接參數、連續重啟時取代舊交接參數 | 6 | | `RestartRequestDeciderTests` | 手動重啟與設定變更兩種入口的確認策略回歸保護 | 3 | +| `SingleInstanceHandoffTests` | `SingleInstanceHandoff` 程式內重啟交接:交接參數解析與移除、啟動動作矩陣(正常啟動/等待接手/喚醒既有實例)、喚醒失敗後的 fallback 判斷(前景被阻擋、交接進行中不得多開視窗);對應 Mutex 提早釋放被搶先取得的競態 | 16 | | `SystemHelperTests` | `SystemHelper.EvaluateGamescopeEnvironment` 在不同 DISPLAY、XDG_CURRENT_DESKTOP 與 DESKTOP_SESSION 環境變數組合下的 Gamescope 環境偵測回歸保護 | 11 | | `TaskExtensionsTests` | `TaskExtensions` CTS 擴充方法與生命週期連結保護 | 12 | +| `TextBoxSelectionExtensionsTests` | 右搖桿延伸選取共用的 `ResolveSelectionAnchor` 錨點解析(無選取、正向、反向、錨點失效)與 `GetWordJumpTarget` 單字跳轉目標(不改變目前選取、方向與邊界);原本三處複本集中後的一致性保護 | 6 | | `VibrationPatternsTests` | `VibrationPatterns` 與方向性震動設定、語意情境解析、能力感知的多段式微震動序列,以及歷程滾輪阻尼感、字數上限硬牆、震動強度預覽、右搖桿選取粒度、組合鍵進入提示與喚起握手回饋的回歸保護 | 37 | -| `VibrationSafetyLimiterTests` | `VibrationSafetyLimiter` 熱保護、Duty Cycle 限制器與極端邊界保護 | 8 | -| **合計** | | **393** | +| `VibrationSafetyLimiterTests` | `VibrationSafetyLimiter` 熱保護、Duty Cycle 限制器與極端邊界保護;馬達數量熱成本倍率正規化,以及所有內建震動模式在冷啟動下不得被拒絕、單次超出預算改為降強度,以及Critical 自動連發(含不同模式交錯)降級、熱負載不失控,以及冷啟動短序列仍完整送出的回歸保護 | 20 | +| **合計** | | **447** | ## 二、執行方式 🚀 @@ -123,7 +128,7 @@ xUnit v3 為每個 `[Fact]` 建立獨立的測試類別實例,`IDisposable.Dis - Microsoft.Testing.Extensions.CodeCoverage:原始碼儲存庫為 [microsoft/codecoverage](https://github.com/microsoft/codecoverage),由 [Microsoft](https://github.com/microsoft) 及其 [貢獻者](https://github.com/microsoft/codecoverage/graphs/contributors) 開發並採用 [MIT License](https://github.com/microsoft/codecoverage/blob/main/LICENSE) 授權,用於測試覆蓋率收集。 - FlaUI.Core:原始碼儲存庫為 [FlaUI/FlaUI](https://github.com/FlaUI/FlaUI),由 [Roman Baeriswyl](https://github.com/Roemer) 及其 [貢獻者](https://github.com/FlaUI/FlaUI/graphs/contributors) 開發並採用 [MIT License](https://github.com/FlaUI/FlaUI/blob/main/LICENSE.txt) 授權,作為 Windows UI 自動化核心函式庫。 - FlaUI.UIA3:原始碼儲存庫為 [FlaUI/FlaUI](https://github.com/FlaUI/FlaUI),由 [Roman Baeriswyl](https://github.com/Roemer) 及其 [貢獻者](https://github.com/FlaUI/FlaUI/graphs/contributors) 開發並採用 [MIT License](https://github.com/FlaUI/FlaUI/blob/main/LICENSE.txt) 授權,作為 UIA3 後端,用於 WinForms UI 冒煙測試。 -- InputWeave.GameInput 0.0.1:原始碼儲存庫固定於 [rubujo/InputWeave.GameInput 封裝來源](https://github.com/rubujo/InputWeave.GameInput/tree/a3985f29c19b35365124d70cfbf0c21d1596ad3e),由 [rubujo](https://github.com/rubujo) 及其 [貢獻者](https://github.com/rubujo/InputWeave.GameInput/graphs/contributors) 開發並採用 [CC0 1.0 Universal](https://github.com/rubujo/InputWeave.GameInput/blob/a3985f29c19b35365124d70cfbf0c21d1596ad3e/LICENSE) 授權,作為 GameInput 直接使用與震動型別守門測試依賴;套件固定於 [../../eng/nuget/InputWeave.GameInput.0.0.1.nupkg](../../eng/nuget/InputWeave.GameInput.0.0.1.nupkg),SHA256 為 `3e9a3d65861a9211380aec6211ccf9f6b9151eae20df6d8917d3e75a98b9b589`。 +- InputWeave.GameInput 0.0.1:原始碼儲存庫固定於 [rubujo/InputWeave.GameInput 封裝來源](https://github.com/rubujo/InputWeave.GameInput/tree/89b148669ffe58d9927495ed0d89f9997bf4962a),由 [rubujo](https://github.com/rubujo) 及其 [貢獻者](https://github.com/rubujo/InputWeave.GameInput/graphs/contributors) 開發並採用 [CC0 1.0 Universal](https://github.com/rubujo/InputWeave.GameInput/blob/89b148669ffe58d9927495ed0d89f9997bf4962a/LICENSE) 授權,作為 GameInput 直接使用與震動型別守門測試依賴;套件固定於 [../../eng/nuget/InputWeave.GameInput.0.0.1.nupkg](../../eng/nuget/InputWeave.GameInput.0.0.1.nupkg),SHA256 為 `3342051977f6f1b91f2a18acfc224456320264dd67633ed4bc41c8188c9fcdb7`。 本測試專案的相關說明詳見本文件;主專案授權與完整聲明仍以 [../../README.md](../../README.md) 及 [../../LICENSE](../../LICENSE) 為準。 diff --git a/tests/InputBox.Tests/RestartActivationCoordinatorTests.cs b/tests/InputBox.Tests/RestartActivationCoordinatorTests.cs index 9be3810..7cb888b 100644 --- a/tests/InputBox.Tests/RestartActivationCoordinatorTests.cs +++ b/tests/InputBox.Tests/RestartActivationCoordinatorTests.cs @@ -65,4 +65,35 @@ public void ConsumePendingActivationRequest_WhenMarkerExpired_ReturnsFalseAndDel Assert.False(coordinator.ConsumePendingActivationRequest()); Assert.False(File.Exists(_markerPath)); } -} \ No newline at end of file + + /// + /// 查詢待處理的重啟請求時不得消費標記,標記仍須保留給重啟後的新執行個體。 + /// + [Fact] + public void HasPendingActivationRequest_DoesNotConsumeMarker() + { + RestartActivationCoordinator coordinator = new(_markerPath, TimeSpan.FromSeconds(5)); + + coordinator.RequestActivationOnNextLaunch(); + + Assert.True(coordinator.HasPendingActivationRequest()); + Assert.True(coordinator.HasPendingActivationRequest()); + Assert.True(coordinator.ConsumePendingActivationRequest()); + Assert.False(coordinator.HasPendingActivationRequest()); + } + + /// + /// 沒有標記或標記已過期時,不應判定為重啟交接進行中。 + /// + [Fact] + public void HasPendingActivationRequest_WithoutMarkerOrExpired_ReturnsFalse() + { + RestartActivationCoordinator coordinator = new(_markerPath, TimeSpan.FromSeconds(5)); + + Assert.False(coordinator.HasPendingActivationRequest()); + + File.WriteAllText(_markerPath, DateTime.UtcNow.AddSeconds(-1).Ticks.ToString(System.Globalization.CultureInfo.InvariantCulture)); + + Assert.False(coordinator.HasPendingActivationRequest()); + } +} diff --git a/tests/InputBox.Tests/RestartProcessLauncherTests.cs b/tests/InputBox.Tests/RestartProcessLauncherTests.cs new file mode 100644 index 0000000..5c9e7bb --- /dev/null +++ b/tests/InputBox.Tests/RestartProcessLauncherTests.cs @@ -0,0 +1,93 @@ +using InputBox.Core.Services; +using System.Diagnostics; +using Xunit; + +namespace InputBox.Tests; + +/// +/// 驗證程式內重啟的啟動資訊與 WinForms Application.Restart() 行為一致,並正確保留命令列引數。 +/// +public sealed class RestartProcessLauncherTests +{ + /// + /// 應以目前執行檔啟動新執行個體,且不透過殼層啟動,才能直接取得新程序識別碼並對齊 Application.Restart()。 + /// + [Fact] + public void CreateStartInfo_UsesExecutablePathWithoutShellExecute() + { + const string exePath = @"C:\Apps\InputBox\InputBox.exe"; + + ProcessStartInfo startInfo = RestartProcessLauncher.CreateStartInfo(exePath, [exePath]); + + Assert.Equal(exePath, startInfo.FileName); + Assert.False(startInfo.UseShellExecute); + Assert.Empty(startInfo.ArgumentList); + } + + /// + /// 命令列第一個元素是執行檔本身,只應轉送其後的引數,並保持原本順序。 + /// + [Fact] + public void CreateStartInfo_ForwardsArgumentsAfterExecutableInOrder() + { + ProcessStartInfo startInfo = RestartProcessLauncher.CreateStartInfo( + "InputBox.exe", + ["InputBox.exe", "--first", "second"]); + + Assert.Equal(["--first", "second"], startInfo.ArgumentList); + } + + /// + /// 含空白或引號的引數應原樣保留,避免像字串拼接那樣在重啟後被拆開或轉義錯誤。 + /// + [Fact] + public void CreateStartInfo_PreservesArgumentsContainingSpacesAndQuotes() + { + const string pathWithSpace = @"D:\My Folder\a.txt"; + const string quoted = "say \"hi\""; + + ProcessStartInfo startInfo = RestartProcessLauncher.CreateStartInfo( + "InputBox.exe", + ["InputBox.exe", pathWithSpace, quoted]); + + Assert.Equal([pathWithSpace, quoted], startInfo.ArgumentList); + } + + /// + /// 執行檔路徑為空白時應立即擲出例外,避免以無效路徑嘗試重啟。 + /// + [Fact] + public void CreateStartInfo_BlankExecutablePath_Throws() + { + Assert.ThrowsAny( + () => RestartProcessLauncher.CreateStartInfo(" ", ["InputBox.exe"])); + } + + /// + /// 指定舊執行個體的程序識別碼時,應在轉送的引數之後附加交接參數,讓新執行個體等待接手 Mutex。 + /// + [Fact] + public void CreateStartInfo_WithHandoffProcessId_AppendsHandoffArgument() + { + ProcessStartInfo startInfo = RestartProcessLauncher.CreateStartInfo( + "InputBox.exe", + ["InputBox.exe", "--first"], + handoffProcessId: 1234); + + Assert.Equal(["--first", "--restart-handoff=1234"], startInfo.ArgumentList); + } + + /// + /// 連續重啟時,舊的交接參數不得被重複轉送,只保留本次的交接參數。 + /// + [Fact] + public void CreateStartInfo_WhenAlreadyHandoffLaunch_ReplacesPreviousHandoffArgument() + { + ProcessStartInfo startInfo = RestartProcessLauncher.CreateStartInfo( + "InputBox.exe", + ["InputBox.exe", "--restart-handoff=111", "--first"], + handoffProcessId: 222); + + Assert.Equal(["--first", "--restart-handoff=222"], startInfo.ArgumentList); + } +} diff --git a/tests/InputBox.Tests/SingleInstanceHandoffTests.cs b/tests/InputBox.Tests/SingleInstanceHandoffTests.cs new file mode 100644 index 0000000..c532953 --- /dev/null +++ b/tests/InputBox.Tests/SingleInstanceHandoffTests.cs @@ -0,0 +1,98 @@ +using InputBox.Core.Services; +using Xunit; + +namespace InputBox.Tests; + +/// +/// 驗證程式內重啟的單一執行個體交接規則:交接參數解析、啟動動作與喚醒失敗後的 fallback 判斷。 +/// 防止重啟時舊實例提早釋放 Mutex,被另外啟動的執行個體搶先取得而讓重啟失效,或在交接期間多開視窗。 +/// +public sealed class SingleInstanceHandoffTests +{ + /// + /// 帶有有效交接參數時,應解析出舊執行個體的程序識別碼。 + /// + [Fact] + public void TryGetHandoffProcessId_WithValidArgument_ReturnsProcessId() + { + bool found = SingleInstanceHandoff.TryGetHandoffProcessId( + ["InputBox.exe", "--other", "--restart-handoff=4321"], + out int processId); + + Assert.True(found); + Assert.Equal(4321, processId); + } + + /// + /// 沒有交接參數或交接參數的數值無效時,不應視為交接啟動。 + /// + [Theory] + [InlineData("--other")] + [InlineData("--restart-handoff=")] + [InlineData("--restart-handoff=abc")] + [InlineData("--restart-handoff=-5")] + [InlineData("--restart-handoff=0")] + public void TryGetHandoffProcessId_WithoutValidArgument_ReturnsFalse(string argument) + { + bool found = SingleInstanceHandoff.TryGetHandoffProcessId(["InputBox.exe", argument], out int processId); + + Assert.False(found); + Assert.Equal(0, processId); + } + + /// + /// 第一個元素是執行檔路徑,即使內容符合交接參數格式也不得被解析。 + /// + [Fact] + public void TryGetHandoffProcessId_IgnoresExecutablePathElement() + { + bool found = SingleInstanceHandoff.TryGetHandoffProcessId(["--restart-handoff=99"], out int processId); + + Assert.False(found); + Assert.Equal(0, processId); + } + + /// + /// 移除交接參數時應保留執行檔路徑與其他引數的原本順序。 + /// + [Fact] + public void RemoveHandoffArguments_KeepsOtherArgumentsInOrder() + { + IReadOnlyList result = SingleInstanceHandoff.RemoveHandoffArguments( + ["InputBox.exe", "--a", "--restart-handoff=1", "--b", "--restart-handoff=2"]); + + Assert.Equal(["InputBox.exe", "--a", "--b"], result); + } + + /// + /// 啟動動作矩陣:取得 Mutex 即正常啟動;未取得時,交接啟動等待接手,其餘喚醒既有實例。 + /// + [Theory] + [InlineData(true, false, "Proceed")] + [InlineData(true, true, "Proceed")] + [InlineData(false, true, "WaitForHandoff")] + [InlineData(false, false, "ActivateExisting")] + public void ResolveStartupAction_ReturnsExpectedAction( + bool createdNew, + bool isHandoffLaunch, + string expected) + { + Assert.Equal(expected, SingleInstanceHandoff.ResolveStartupAction(createdNew, isHandoffLaunch).ToString()); + } + + /// + /// fallback 判斷矩陣:前景切換被阻擋時一律結束;重啟交接進行中時結束以免多開視窗;其餘允許 fallback。 + /// + [Theory] + [InlineData(false, false, "ExitForegroundBlocked")] + [InlineData(false, true, "ExitForegroundBlocked")] + [InlineData(true, true, "ExitRestartHandoffPending")] + [InlineData(true, false, "TryFallback")] + public void ResolveFallback_ReturnsExpectedDecision( + bool fallbackPermitted, + bool restartHandoffPending, + string expected) + { + Assert.Equal(expected, SingleInstanceHandoff.ResolveFallback(fallbackPermitted, restartHandoffPending).ToString()); + } +} diff --git a/tests/InputBox.Tests/TextBoxSelectionExtensionsTests.cs b/tests/InputBox.Tests/TextBoxSelectionExtensionsTests.cs new file mode 100644 index 0000000..a6226cc --- /dev/null +++ b/tests/InputBox.Tests/TextBoxSelectionExtensionsTests.cs @@ -0,0 +1,102 @@ +using InputBox.Core.Extensions; +using Xunit; + +namespace InputBox.Tests; + +/// +/// 驗證右搖桿延伸選取共用的 TextBox 擴充方法:錨點解析與單字跳轉目標計算。 +/// 這些邏輯原本在主視窗、數值輸入與片語編輯三處各有一份複本,集中後以此測試保護行為一致。 +/// +public sealed class TextBoxSelectionExtensionsTests +{ + /// + /// 沒有選取範圍時,應以目前游標位置作為新錨點,活動邊緣與錨點相同。 + /// + [Fact] + public void ResolveSelectionAnchor_NoSelection_UsesCaretAsAnchor() + { + using TextBox textBox = new() { Text = "hello world" }; + textBox.Select(3, 0); + + (int anchor, int activeEdge) = textBox.ResolveSelectionAnchor(currentAnchor: 7); + + Assert.Equal(3, anchor); + Assert.Equal(3, activeEdge); + } + + /// + /// 錨點位於選取左端時,活動邊緣應在右端,並沿用既有錨點。 + /// + [Fact] + public void ResolveSelectionAnchor_AnchorAtStart_ActiveEdgeIsSelectionEnd() + { + using TextBox textBox = new() { Text = "hello world" }; + textBox.Select(2, 4); + + (int anchor, int activeEdge) = textBox.ResolveSelectionAnchor(currentAnchor: 2); + + Assert.Equal(2, anchor); + Assert.Equal(6, activeEdge); + } + + /// + /// 錨點位於選取右端(反向選取)時,活動邊緣應在左端,並沿用既有錨點。 + /// + [Fact] + public void ResolveSelectionAnchor_AnchorAtEnd_ActiveEdgeIsSelectionStart() + { + using TextBox textBox = new() { Text = "hello world" }; + textBox.Select(2, 4); + + (int anchor, int activeEdge) = textBox.ResolveSelectionAnchor(currentAnchor: 6); + + Assert.Equal(6, anchor); + Assert.Equal(2, activeEdge); + } + + /// + /// 選取範圍兩端都不等於既有錨點(例如使用者以滑鼠改變過選取)時,應以選取起點重新作為錨點。 + /// + [Fact] + public void ResolveSelectionAnchor_StaleAnchor_ResetsToSelectionStart() + { + using TextBox textBox = new() { Text = "hello world" }; + textBox.Select(2, 4); + + (int anchor, int activeEdge) = textBox.ResolveSelectionAnchor(currentAnchor: 9); + + Assert.Equal(2, anchor); + Assert.Equal(6, activeEdge); + } + + /// + /// 計算單字跳轉目標時不得改變文字方塊目前的選取範圍。 + /// + [Fact] + public void GetWordJumpTarget_DoesNotChangeCurrentSelection() + { + using TextBox textBox = new() { Text = "hello world again" }; + textBox.Select(1, 3); + + int target = textBox.GetWordJumpTarget(caret: 0, forward: true); + + Assert.True(target > 0); + Assert.Equal(1, textBox.SelectionStart); + Assert.Equal(3, textBox.SelectionLength); + } + + /// + /// 向右與向左的單字跳轉應分別往對應方向移動,且在文字邊界時不越界。 + /// + [Fact] + public void GetWordJumpTarget_MovesInRequestedDirectionWithinBounds() + { + using TextBox textBox = new() { Text = "hello world again" }; + + int forward = textBox.GetWordJumpTarget(caret: 0, forward: true); + int backward = textBox.GetWordJumpTarget(caret: textBox.TextLength, forward: false); + + Assert.InRange(forward, 1, textBox.TextLength); + Assert.InRange(backward, 0, textBox.TextLength - 1); + } +} diff --git a/tests/InputBox.Tests/VibrationSafetyLimiterTests.cs b/tests/InputBox.Tests/VibrationSafetyLimiterTests.cs index 3895773..ccc30dd 100644 --- a/tests/InputBox.Tests/VibrationSafetyLimiterTests.cs +++ b/tests/InputBox.Tests/VibrationSafetyLimiterTests.cs @@ -1,4 +1,6 @@ -using InputBox.Core.Input; +using InputBox.Core.Feedback; +using InputBox.Core.Input; +using System.Reflection; using Xunit; namespace InputBox.Tests; @@ -138,11 +140,12 @@ public void TryApply_WhenThermalCostMultiplierHigher_ShouldScaleEarlier() out _, thermalCostMultiplier: 4.0); + // 第二次呼叫刻意落在 Critical 連發視窗之外,避免被視為自動連發而降級。 bool normalSecond = limiterNormal.TryApply( 45_000, 200, VibrationPriority.Critical, - nowMs: 1, + nowMs: 600, out ushort normalSecondStrength, out _); @@ -150,7 +153,7 @@ public void TryApply_WhenThermalCostMultiplierHigher_ShouldScaleEarlier() 45_000, 200, VibrationPriority.Critical, - nowMs: 1, + nowMs: 600, out ushort fourMotorSecondStrength, out _, thermalCostMultiplier: 4.0); @@ -246,4 +249,260 @@ public void TryApply_WhenStrengthIsMaxValue_ShouldClampToSafetyCeiling() Assert.True(accepted); Assert.InRange(adjustedStrength, 1, 60_000); } -} \ No newline at end of file + + /// + /// 馬達數量倍率應以雙主馬達為基準正規化,並限制在 1 到 4 顆馬達的範圍內。 + /// + [Theory] + [InlineData(0, 0.5)] + [InlineData(1, 0.5)] + [InlineData(2, 1.0)] + [InlineData(4, 2.0)] + [InlineData(8, 2.0)] + public void GetMotorThermalCostMultiplier_NormalizesToDualMotorBaseline(int motorCount, double expected) + { + Assert.Equal(expected, VibrationSafetyLimiter.GetMotorThermalCostMultiplier(motorCount)); + } + + /// + /// 回歸保護:限制器剛啟動、尚無熱負載時,所有內建震動模式在各種強度與馬達數量下都必須能送出, + /// 且強度不得低於 Normal 優先級保底比例。先前「複製成功」等重要回饋會因單次熱成本超過硬上限而被直接拒絕。 + /// + [Fact] + public void TryApply_AllBuiltInPatternsFromColdState_AreNeverBlocked() + { + FieldInfo[] patternFields = [.. typeof(VibrationPatterns) + .GetFields(BindingFlags.Public | BindingFlags.Static) + .Where(f => f.FieldType == typeof(VibrationProfile))]; + + Assert.NotEmpty(patternFields); + + float[] intensities = [0.3f, 0.7f, 1.0f]; + int[] motorCounts = [1, 2, 4]; + + foreach (FieldInfo field in patternFields) + { + VibrationProfile pattern = (VibrationProfile)field.GetValue(null)!; + + foreach (float intensity in intensities) + { + VibrationProfile profile = pattern.ApplyIntensityMultiplier(intensity); + + if (profile.Strength == 0) + { + continue; + } + + foreach (int motorCount in motorCounts) + { + var limiter = new VibrationSafetyLimiter(); + + bool accepted = limiter.TryApplyWithDiagnostics( + profile.Strength, + profile.Duration, + VibrationPriority.Normal, + nowMs: 1, + out ushort adjustedStrength, + out _, + out VibrationLimiterDebugInfo diagnostics, + thermalCostMultiplier: VibrationSafetyLimiter.GetMotorThermalCostMultiplier(motorCount)); + + Assert.True( + accepted, + $"{field.Name} intensity={intensity} motors={motorCount} flags={diagnostics.Flags}"); + Assert.True( + adjustedStrength >= (Math.Min((int)profile.Strength, 60_000) * 0.35) - 1, + $"{field.Name} intensity={intensity} motors={motorCount} strength={adjustedStrength}"); + } + } + } + } + + /// + /// 單次請求超出剩餘熱預算時,Normal 優先級應降低強度送出並標記 ScaledByThermalOverflow,而不是直接拒絕。 + /// + [Fact] + public void TryApply_Normal_WhenSingleRequestExceedsBudget_ShouldScaleInsteadOfBlock() + { + var limiter = new VibrationSafetyLimiter(); + + bool accepted = limiter.TryApplyWithDiagnostics( + 60_000, + 150, + VibrationPriority.Normal, + nowMs: 1, + out ushort adjustedStrength, + out _, + out VibrationLimiterDebugInfo diagnostics, + thermalCostMultiplier: VibrationSafetyLimiter.GetMotorThermalCostMultiplier(4)); + + Assert.True(accepted); + Assert.True(adjustedStrength < 60_000); + Assert.True(diagnostics.Flags.HasFlag(VibrationLimiterFlags.ScaledByThermalOverflow)); + Assert.True(diagnostics.ThermalLoad <= 180.0 * 1.05); + } + + /// + /// 熱負載已超過溢出上限、剩餘預算不足以維持保底強度時,Normal 優先級仍應被拒絕以保護馬達。 + /// + [Fact] + public void TryApply_Normal_WhenAlreadyOverheated_ShouldStillBeBlocked() + { + var limiter = new VibrationSafetyLimiter(thermalTauMs: 1_000_000); + + // Critical 不受溢出拒絕限制,用來把熱負載推高到溢出上限之上;間隔超過連發視窗以免被降級。 + for (int i = 0; i < 10; i++) + { + limiter.TryApply(60_000, 200, VibrationPriority.Critical, nowMs: 1 + (i * 600), out _, out _); + } + + bool accepted = limiter.TryApplyWithDiagnostics( + 60_000, + 200, + VibrationPriority.Normal, + nowMs: 6_100, + out _, + out _, + out VibrationLimiterDebugInfo diagnostics); + + Assert.False(accepted); + Assert.True(diagnostics.Flags.HasFlag(VibrationLimiterFlags.BlockedByThermalOverflow)); + } + + /// + /// 回歸保護:按住方向鍵頂著邊界時,操作失敗回饋(Critical)約每 150ms 自動連發。 + /// 第一次應以 Critical 完整送出,之後的連發應降為 Normal 交由熱保護節流,熱負載不得持續累積失控。 + /// 修正前實測 5 秒內連發 25 次會讓熱負載累積到硬上限的 5.7 倍。 + /// + [Fact] + public void TryApply_RepeatedIdenticalCritical_IsDowngradedAndThermalStaysBounded() + { + var limiter = new VibrationSafetyLimiter(); + double multiplier = VibrationSafetyLimiter.GetMotorThermalCostMultiplier(4); + + bool first = limiter.TryApplyWithDiagnostics( + 53_861, + 200, + VibrationPriority.Critical, + nowMs: 1, + out ushort firstStrength, + out _, + out VibrationLimiterDebugInfo firstDiagnostics, + multiplier); + + Assert.True(first); + Assert.Equal(53_861, firstStrength); + Assert.False(firstDiagnostics.Flags.HasFlag(VibrationLimiterFlags.DowngradedCriticalRepeat)); + + double firstLoad = firstDiagnostics.ThermalLoad; + double maxLoad = firstLoad; + + for (int i = 1; i < 25; i++) + { + limiter.TryApplyWithDiagnostics( + 53_861, + 200, + VibrationPriority.Critical, + nowMs: 1 + (i * 150), + out _, + out _, + out VibrationLimiterDebugInfo diagnostics, + multiplier); + + Assert.True(diagnostics.Flags.HasFlag(VibrationLimiterFlags.DowngradedCriticalRepeat), $"repeat {i}"); + maxLoad = Math.Max(maxLoad, diagnostics.ThermalLoad); + } + + Assert.True(maxLoad <= firstLoad + 0.001, $"maxLoad={maxLoad:F2} firstLoad={firstLoad:F2}"); + } + + /// + /// 停止連發超過連發視窗後,下一次相同的 Critical 請求應恢復為 Critical,不得被永久降級。 + /// + [Fact] + public void TryApply_CriticalAfterRepeatWindowElapsed_IsNotDowngraded() + { + var limiter = new VibrationSafetyLimiter(); + + limiter.TryApply(53_861, 200, VibrationPriority.Critical, nowMs: 1, out _, out _); + + limiter.TryApplyWithDiagnostics( + 53_861, + 200, + VibrationPriority.Critical, + nowMs: 700, + out _, + out _, + out VibrationLimiterDebugInfo diagnostics); + + Assert.False(diagnostics.Flags.HasFlag(VibrationLimiterFlags.DowngradedCriticalRepeat)); + } + + /// + /// 不同的 Critical 模式交錯快速出現時,也必須被判定為連發而節流,不得藉由輪流改變強度或時長規避。 + /// + [Fact] + public void TryApply_InterleavedCriticalProfiles_AreStillThrottled() + { + var limiter = new VibrationSafetyLimiter(); + double multiplier = VibrationSafetyLimiter.GetMotorThermalCostMultiplier(4); + + limiter.TryApplyWithDiagnostics( + 53_861, + 200, + VibrationPriority.Critical, + nowMs: 1, + out _, + out _, + out VibrationLimiterDebugInfo firstDiagnostics, + multiplier); + + double firstLoad = firstDiagnostics.ThermalLoad; + double maxLoad = firstLoad; + + for (int i = 1; i < 20; i++) + { + ushort strength = i % 2 == 0 ? (ushort)53_861 : (ushort)49_312; + + limiter.TryApplyWithDiagnostics( + strength, + 200, + VibrationPriority.Critical, + nowMs: 1 + (i * 150), + out _, + out _, + out VibrationLimiterDebugInfo diagnostics, + multiplier); + + Assert.True(diagnostics.Flags.HasFlag(VibrationLimiterFlags.DowngradedCriticalRepeat), $"repeat {i}"); + maxLoad = Math.Max(maxLoad, diagnostics.ThermalLoad); + } + + Assert.True(maxLoad <= firstLoad + 0.001, $"maxLoad={maxLoad:F2} firstLoad={firstLoad:F2}"); + } + + /// + /// 冷啟動時的短 Critical 序列(例如控制器識別的前後兩段),第二段雖被視為連發而降級,仍應以原強度完整送出。 + /// + [Fact] + public void TryApply_CriticalSequenceFromColdState_SecondStepStillDeliveredAtFullStrength() + { + var limiter = new VibrationSafetyLimiter(); + + limiter.TryApply(43_000, 110, VibrationPriority.Critical, nowMs: 1, out _, out _); + + bool accepted = limiter.TryApplyWithDiagnostics( + 55_000, + 130, + VibrationPriority.Critical, + nowMs: 150, + out ushort adjustedStrength, + out int adjustedDurationMs, + out VibrationLimiterDebugInfo diagnostics); + + Assert.True(accepted); + Assert.True(diagnostics.Flags.HasFlag(VibrationLimiterFlags.DowngradedCriticalRepeat)); + Assert.Equal(55_000, adjustedStrength); + Assert.Equal(130, adjustedDurationMs); + } +}