From 5b75cd05e674d59b2e2d005eecb2da045bedfb71 Mon Sep 17 00:00:00 2001 From: TomokoKwanXbox <130513966+TomokoKwanXbox@users.noreply.github.com> Date: Tue, 29 Sep 2026 13:29:19 -0700 Subject: [PATCH] Add opt-in GamepadKeyRouting XAML optional change for gamepad navigation (#11853) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Fixes Closes #63117176 https://task.ms/63117176 ## PR Type - [ ] Bugfix - [x] Feature - [ ] Code style update (formatting, renaming) - [ ] Refactoring (no functional changes, no API changes) - [ ] Build related changes - [ ] Documentation content changes - [ ] Other (please describe): ## Description Adds an opt-in `GamepadKeyRouting` XAML optional change that lets WinUI 3 apps respond to gamepad input using XAML's existing keyboard navigation, restoring behavior that System XAML provided out of the box. When enabled, `Application.Start()` calls the OS API `Windows.UI.Input.GamepadKeyRoutingConfiguration.TrySetKeyRoutingEnabled(true)`, which routes gamepad input into the process as keyboard virtual keys. XY focus navigation, Enter/Space to invoke, and Esc to dismiss then work from a gamepad with no app-specific code. Because the OS API requires UniversalApiContract 19 (Windows 11, 24H2) — newer than the 17763 downlevel floor this repo targets — the call lives in a new light-up translation unit (`GamepadKeyRoutingLightup.cpp`) compiled without the PCH and with the contract version undefined, following the existing `DXamlCoreLightup.cpp` pattern. ### Current Behavior A WinUI 3 app receives no keyboard-equivalent input from a gamepad. Gamepad navigation that worked in System XAML requires apps to implement their own input handling. There is no `XamlChangeId` for gamepad routing. ### New Behavior `Microsoft.UI.Xaml.Settings.XamlChangeId` gains a `GamepadKeyRouting` value (`63117108`). It is off by default. Apps opt in either via the project property: GamepadKeyRouting or by calling `XamlOptionalChanges.EnableChange(XamlChangeId.GamepadKeyRouting)` before `Application.Start()`. When enabled, `FrameworkApplicationFactory::StartImpl` enables routing for the process. Applied only when the windowing model is `ClassicDesktop` or `Universal` — i.e. a full WinUI app. The OS setting is process-wide, so XAML islands deliberately never reach this call and stay at the OS default. Apps can opt back out at runtime via `TrySetKeyRoutingEnabled(false)` after `Application.Start()`. Failure is non-fatal at every step. The code gates on `ApiInformation` (contract present, type present), then `IsSupported()`, then honors the `TrySetKeyRoutingEnabled` return value. On downlevel OS, unsupported hardware, or OS refusal it returns `S_OK` and the app runs normally without gamepad navigation. Also included: - `Application.Start()` now locks the optional-changes set before reading it, so app code cannot alter selected behavior once startup has begun. - Windows SDK for C++ projects bumped `10.0.22621.755` → `10.0.26100.9169` across all `packages*.config`, required for the contract 19 headers. ## Customer Impact User-facing and additive. Gamepad users get working navigation with no code which is a gap closed versus System XAML. Gracefully degrades: an opted-in app on Windows earlier than 11 24H2, or on hardware where the API is unsupported, behaves exactly as it does today. ## Regression Potential - [ ] Low risk — isolated change, limited scope - [x] Medium risk — touches shared components or public APIs - [ ] High risk — architectural or breaking API change 1. **Windows SDK bump** (`10.0.22621.755` → `10.0.26100.9169`) 1. **`Application.Start()` startup path** is shared by every WinUI app. Mitigating: the new OS call is confined to a separate TU, reached only when opted in, and every failure path returns `S_OK`. Test infra enables `GamepadKeyRouting` by default for test runs, so existing suites exercise the enabled path. ## How Has This Been Tested? - Manually validated end-to-end with WinUI Gallery deployed as an MSIX with `GamepadKeyRouting`: gamepad navigation confirmed working. - Tested on an older OS without Windows.UI.Input.GamepadKeyRoutingConfiguration. WinUIGallery app works as expected without gamepad navigation enabled. - Built and verified the opposite configuration (``), confirming the XAML compiler emits `XamlOptionalChanges.DisableChange(XamlChangeId.GamepadKeyRouting)` into the generated `Main()` and that the app runs normally with routing off. - [x] I have performed a self-review of my own code - [ ] I have added tests to cover my changes - [x] Existing tests builds, will run a test pass on the PR --- .../GamepadKeyRoutingState.cpp | 81 ++++++++ .../GamepadKeyRoutingState.h | 13 ++ .../WinUICppDesktopSampleApp/MainWindow.cpp | 5 +- .../WinUICppDesktopSampleApp/MainWindow.xaml | 4 + .../WinUICppDesktopSampleApp.vcxproj | 4 +- .../WinUICppDesktopSampleAppTests.cs | 24 +++ .../Utilities/APITestBase.cs | 15 ++ docs/design-notes/gamepad-navigation.md | 181 ++++++++++++------ dxaml/test/infra/client/lib/WindowHelper.cpp | 5 + .../xcp/components/metadata/inc/EnumDefs.g.h | 1 + .../inc/OptionalChangeState.h | 6 + .../core/microsoft.ui.xaml.coretypes2.idl | 4 +- dxaml/xcp/dxaml/lib/DXamlCoreLightup.cpp | 40 ++++ .../lib/FrameworkApplication_Partial.cpp | 12 ++ .../xcp/dxaml/lib/GamepadKeyRoutingLightup.h | 21 ++ .../dxaml/lib/XamlOptionalChanges_Partial.cpp | 2 + .../Model/Microsoft.UI.Xaml.Settings.cs | 3 + packages.arm64.config | 2 +- packages.arm64ec.config | 2 +- packages.config | 2 +- packages.x64.config | 2 +- packages.x86.config | 2 +- 22 files changed, 363 insertions(+), 68 deletions(-) create mode 100644 Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/GamepadKeyRoutingState.cpp create mode 100644 Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/GamepadKeyRoutingState.h create mode 100644 dxaml/xcp/dxaml/lib/GamepadKeyRoutingLightup.h diff --git a/Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/GamepadKeyRoutingState.cpp b/Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/GamepadKeyRoutingState.cpp new file mode 100644 index 000000000..72c0d6e0e --- /dev/null +++ b/Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/GamepadKeyRoutingState.cpp @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#include "pch.h" +#include "GamepadKeyRoutingState.h" + +// GamepadKeyRoutingConfiguration ships in UniversalApiContract 19, which this app's C++/WinRT +// projection does not know about: samples intentionally build against the 17763 contract metadata +// (see eng\lightup.targets). The Windows SDK ABI headers are not subject to that pin and do +// declare the type, so this talks to the ABI interface directly. Those headers also introduce a +// global ::Windows namespace that collides with the using-directives in MainWindow.cpp, which is +// why this lives in its own translation unit. +#undef WINDOWS_FOUNDATION_UNIVERSALAPICONTRACT_VERSION +#include +#include +#include + +namespace +{ + winrt::hstring RuntimeClassName(wchar_t const* name) + { + return winrt::hstring{ name }; + } +} + +namespace WinUICppDesktopSampleApp +{ + std::wstring GetGamepadKeyRoutingState() + { + namespace wf_metadata = ABI::Windows::Foundation::Metadata; + namespace wui = ABI::Windows::UI::Input; + + auto const apiInformationName = RuntimeClassName(L"Windows.Foundation.Metadata.ApiInformation"); + winrt::com_ptr apiInformation; + if (FAILED(RoGetActivationFactory( + static_cast(winrt::get_abi(apiInformationName)), + __uuidof(wf_metadata::IApiInformationStatics), + apiInformation.put_void()))) + { + return L"Unavailable"; + } + + auto const contractName = RuntimeClassName(L"Windows.Foundation.UniversalApiContract"); + boolean isContractPresent = false; + if (FAILED(apiInformation->IsApiContractPresentByMajor( + static_cast(winrt::get_abi(contractName)), + 19, + &isContractPresent)) || + !isContractPresent) + { + return L"Unavailable"; + } + + auto const configurationName = RuntimeClassName(L"Windows.UI.Input.GamepadKeyRoutingConfiguration"); + winrt::com_ptr gamepadKeyRouting; + if (FAILED(RoGetActivationFactory( + static_cast(winrt::get_abi(configurationName)), + __uuidof(wui::IGamepadKeyRoutingConfigurationStatics), + gamepadKeyRouting.put_void()))) + { + return L"Unavailable"; + } + + // IsSupported() reports the same OS velocity feature that gates the routing request + // itself, so a false here means the machine cannot route gamepad input at all. That is + // not a product failure, and the test treats it as inconclusive. + boolean isSupported = false; + if (FAILED(gamepadKeyRouting->IsSupported(&isSupported)) || !isSupported) + { + return L"Unavailable"; + } + + boolean isEnabled = false; + if (FAILED(gamepadKeyRouting->get_IsKeyRoutingEnabled(&isEnabled))) + { + return L"Unavailable"; + } + + return isEnabled ? L"Enabled" : L"Disabled"; + } +} diff --git a/Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/GamepadKeyRoutingState.h b/Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/GamepadKeyRoutingState.h new file mode 100644 index 000000000..51d6b2c73 --- /dev/null +++ b/Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/GamepadKeyRoutingState.h @@ -0,0 +1,13 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +#pragma once + +#include + +namespace WinUICppDesktopSampleApp +{ + // Returns the process-wide gamepad key routing state as "Enabled", "Disabled", or + // "Unavailable". See GamepadKeyRoutingState.cpp for why this lives in its own file. + std::wstring GetGamepadKeyRoutingState(); +} diff --git a/Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/MainWindow.cpp b/Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/MainWindow.cpp index d2aefeb04..a9af7e7a1 100644 --- a/Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/MainWindow.cpp +++ b/Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/MainWindow.cpp @@ -1,4 +1,4 @@ -// Copyright (c) Microsoft Corporation. +// Copyright (c) Microsoft Corporation. // Licensed under the MIT License. #include "pch.h" @@ -13,6 +13,7 @@ #include #include #include "ShutdownOrderValidation.h" +#include "GamepadKeyRoutingState.h" using namespace winrt; using namespace Microsoft::UI::Xaml; @@ -36,6 +37,8 @@ namespace winrt::WinUICppDesktopSampleApp::implementation { InitializeComponent(); + textBlockGamepadKeyRouting().Text(::WinUICppDesktopSampleApp::GetGamepadKeyRoutingState()); + SetBackground(m_currentBackground); // sets up window chrome with the provided custom title bar diff --git a/Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/MainWindow.xaml b/Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/MainWindow.xaml index a9145a8b1..c10aabdca 100644 --- a/Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/MainWindow.xaml +++ b/Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/MainWindow.xaml @@ -20,6 +20,10 @@ + + + + diff --git a/Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp.vcxproj b/Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp.vcxproj index 227e09ef8..a953dc037 100644 --- a/Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp.vcxproj +++ b/Samples/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp/WinUICppDesktopSampleApp.vcxproj @@ -49,7 +49,7 @@ 10.0 - _Reserved + _Reserved,GamepadKeyRouting _Reserved @@ -137,6 +137,7 @@ MarkupWindow.xaml + @@ -186,6 +187,7 @@ + diff --git a/controls/test/MUXControls.Test/ScenarioAppTests/WinUICppDesktopSampleAppTests.cs b/controls/test/MUXControls.Test/ScenarioAppTests/WinUICppDesktopSampleAppTests.cs index 0daea7b0a..7c614d112 100644 --- a/controls/test/MUXControls.Test/ScenarioAppTests/WinUICppDesktopSampleAppTests.cs +++ b/controls/test/MUXControls.Test/ScenarioAppTests/WinUICppDesktopSampleAppTests.cs @@ -90,6 +90,30 @@ namespace Microsoft.UI.Xaml.Tests.MUXControls.InteractionTests WinUISampleAppTestsUtils.SelectCheckbox("checkBox"); } + [TestMethod] + [Description("Validate that enabling the GamepadKeyRouting XamlOptionalChange makes the framework turn on gamepad key routing in the OS.")] + public void GamepadKeyRoutingIsEnabledAtStartupTest() + { + // WinUICppDesktopSampleApp sets ...GamepadKeyRouting, + // so Application::Start() should have called the OS gamepad key routing API before the + // main window was created. The app sampled the OS state at that point; here we read back + // what it saw. This guards against codeflow or merge issues silently dropping the call. + var textBlockAsUIObject = FindElement.ByName("textBlockGamepadKeyRouting"); + Verify.IsNotNull(textBlockAsUIObject); + string state = new TextBlock(textBlockAsUIObject).DocumentText; + Log.Comment($"textBlockGamepadKeyRouting={state}"); + + if (state == "Unavailable") + { + // The OS on this machine cannot route gamepad input, so there is nothing for the + // framework to have enabled and there is nothing to verify. + Log.Warning("Gamepad key routing is not supported on this OS - skipping verification."); + return; + } + + Verify.AreEqual("Enabled", state); + } + [TestMethod] [Description("Basic tests of a few MUX controls: TextBlock, CheckBox, Button, ToggleButton, Flyout.")] [TestProperty("Ignore", "True")] // http://task.ms/45771651 diff --git a/controls/test/MUXControlsTestApp/Utilities/APITestBase.cs b/controls/test/MUXControlsTestApp/Utilities/APITestBase.cs index 367b6cc28..cae8df552 100644 --- a/controls/test/MUXControlsTestApp/Utilities/APITestBase.cs +++ b/controls/test/MUXControlsTestApp/Utilities/APITestBase.cs @@ -64,6 +64,7 @@ namespace MUXControlsTestApp.Utilities Verify.IsTrue(XamlOptionalChanges.EnableChange(XamlChangeId.OptimizeApplyStyles)); Verify.IsTrue(XamlOptionalChanges.EnableChange(XamlChangeId.DefaultStyleOptimizations)); Verify.IsTrue(XamlOptionalChanges.EnableChange(XamlChangeId.DeferContextFlyoutInit)); + Verify.IsTrue(XamlOptionalChanges.EnableChange(XamlChangeId.GamepadKeyRouting)); } public void UpdateXamlOptionalChanges() @@ -170,9 +171,23 @@ namespace MUXControlsTestApp.Utilities { changeId = XamlChangeId.DeferContextFlyoutInit; } + else if (string.Equals(name, "GamepadKeyRouting", StringComparison.OrdinalIgnoreCase)) + { + changeId = XamlChangeId.GamepadKeyRouting; + } Verify.AreNotEqual(changeId, XamlChangeId._Reserved, "Unknown XamlChangeId: " + name); + if (changeId == XamlChangeId.GamepadKeyRouting && !enabled) + { + // Gamepad key routing is applied to the process by Application.Start(), which has + // already run by the time test setup reads this property. Clearing the optional + // change here only updates the bit, leaving the process still routing gamepad input + // as keys, so the test would silently run against the wrong configuration. + Verify.Fail("XamlOptionalChanges test override cannot disable GamepadKeyRouting: the " + + "process-wide routing setting is already applied by Application.Start()."); + } + xamlOptionalChanges.Add(changeId, enabled); pos = sepPos + 1; } diff --git a/docs/design-notes/gamepad-navigation.md b/docs/design-notes/gamepad-navigation.md index f0801819f..9e0ce7585 100644 --- a/docs/design-notes/gamepad-navigation.md +++ b/docs/design-notes/gamepad-navigation.md @@ -13,7 +13,12 @@ In System XAML (UWP XAML), this gamepad-driven navigation was on by default — for free without any opt-in. WinUI 3 XAML does not turn it on automatically, so today the same app loses gamepad navigation unless it (or its host) explicitly enables it. We consider that gap a regression from System XAML, and this spec describes closing it by having the -WinUI framework enable gamepad key routing on by default, the way System XAML did. +WinUI framework enable gamepad key routing during XAML startup, the way System XAML did. + +Because this changes existing behavior, the framework's enable is gated behind a +**XAML optional change**, `XamlChangeId.GamepadKeyRouting`. An app opts in explicitly today; +the change becomes the default in a later release. See +[Opting in with XamlOptionalChanges](#opting-in-with-xamloptionalchanges). Whether gamepad input is delivered to an app as virtual keys is controlled by an existing operating-system API, `Windows.UI.Input.GamepadKeyRoutingConfiguration`. This is a @@ -21,8 +26,9 @@ process-wide setting: when key routing is enabled, gamepad input arrives as virt for the whole process; when it is disabled, it does not. Today an app (or host) has to turn this on itself. This spec describes enabling it inside -the WinUI framework, so that a full WinUI app gets gamepad-driven navigation on by default, -while still allowing the app to opt out. +the WinUI framework, so that a full WinUI app gets gamepad-driven navigation without writing +input code — initially by enabling the `GamepadKeyRouting` optional change, and by default in +a later release — while still allowing the app to opt out. The framework enables key routing only for a **full WinUI app** — one that starts XAML by calling `Application.Start()`. A process that merely embeds WinUI as a **XAML Island** is @@ -30,15 +36,43 @@ deliberately left alone: the framework does not flip the process-wide switch for host's non-XAML input code is unaffected. A host that wants routing can still enable it itself. -This spec does not introduce a new API. `GamepadKeyRoutingConfiguration` already ships +This spec introduces no new *runtime class*. `GamepadKeyRoutingConfiguration` already ships in the OS (`Windows.Foundation.UniversalApiContract`, version 19). The work here is a -framework-internal call to that existing API at the right point in XAML startup, plus -guidance for how an app opts out. The API surface is reproduced in -[API Details](#api-details) for reference only. +framework-internal call to that existing API at the right point in XAML startup, gated by a +new `XamlChangeId` enum value, plus guidance for how an app opts in and out. The surfaces are +listed in [API Details](#api-details). # Conceptual pages (How To) -## Gamepad navigation is on by default +## Opting in with XamlOptionalChanges + +Enabling gamepad key routing changes how an existing app receives input, so it is delivered +as a **XAML optional change** rather than switched on unconditionally. The change id is +`XamlChangeId.GamepadKeyRouting`. + +An app opts in with the `EnabledXamlOptionalChanges` MSBuild property, which makes the XAML +compiler emit the enable call into your generated `App` entry point, before +`Application.Start()` runs: + +```xml + + GamepadKeyRouting + +``` + +Optional changes are locked when XAML initializes, so enabling the change after +`Application.Start()` is too late — see +[Where the framework makes the call](#where-the-framework-makes-the-call). + +Planned rollout: + +| Release | State | +| --- | --- | +| 2.x | Available, disabled by default (explicit opt-in as above) | +| 3.x | Enabled by default; apps can still opt out | +| 4.x | Always enabled; the optional change is deprecated | + +## Gamepad navigation once the change is enabled When your full WinUI app starts XAML — which it does by calling `Application.Start()` from its generated entry point — the framework enables gamepad key routing for the process. @@ -47,7 +81,7 @@ keys) is delivered to your app as keyboard virtual keys. This means WinUI's norm and focus behaviors — XY focus navigation, `Enter`/`Space` to invoke, `Esc` to dismiss, and similar — respond to the gamepad automatically, with no additional code in your app. -If your app does nothing special, this is the recommended experience: you get gamepad +If your app does nothing further, this is the recommended experience: you get gamepad navigation for free, and if your app has no interest in gamepad input it can simply ignore those virtual keys. @@ -87,9 +121,9 @@ setting. So the framework has to be deliberate about *which processes* it turns It enables routing only from the **full-app entry point**, `Application.Start()`. A normal WinUI app reaches that method from its generated entry point, owns its process, and gets -gamepad navigation on by default — process-wide is exactly what it wants. -A process that embeds WinUI only as a **XAML Island** never calls `Application.Start()`, so -the framework does not auto-enable routing for it. +gamepad navigation once the optional change is enabled — process-wide is exactly what it +wants. A process that embeds WinUI only as a **XAML Island** never calls `Application.Start()`, +so the framework does not auto-enable routing for it, regardless of the optional change. One residual point worth stating plainly: within a full WinUI app the setting is still process-wide, so if that app *also* hosts non-XAML UI in the same process, that UI's input @@ -108,17 +142,18 @@ The framework makes the call once per process, from the full-app entry point ```cpp // Inside Application.Start() (FrameworkApplicationFactory::StartImpl), on the branch that // starts a full WinUI app. Island hosts don't reach here, so their process is left alone. -// IsApiContractPresent checks the OS actually has the API (it ships in a newer contract than -// WinUI's minimum OS); IsSupported checks the feature is usable on this device. -if (Windows::Foundation::Metadata::ApiInformation::IsApiContractPresent( - L"Windows.Foundation.UniversalApiContract", 19) && - Windows::UI::Input::GamepadKeyRoutingConfiguration::IsSupported()) +// The optional change must have been enabled before Application.Start(); the contract check +// confirms the OS actually has the API, which ships in a newer contract than WinUI's minimum OS. +if (OptionalChangeState::IsGamepadKeyRoutingEnabled()) { - Windows::UI::Input::GamepadKeyRoutingConfiguration::TrySetKeyRoutingEnabled(true); + if (ApiInformation::IsApiContractPresentByMajor(L"Windows.Foundation.UniversalApiContract", 19)) + { + GamepadKeyRoutingConfiguration::TrySetKeyRoutingEnabled(true); + } } ``` -Your app does not need to do anything to get this behavior. +Your app does not need to do anything beyond enabling the optional change. ## Opting out from your app @@ -135,9 +170,8 @@ public partial class App : Application // Opt out of gamepad-to-key routing; the framework enabled it during XAML init. // IsApiContractPresent guards OS versions without the API (the type ships in a newer - // contract than WinUI's minimum OS); IsSupported guards devices where it's unusable. - if (Windows.Foundation.Metadata.ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 19) && - Windows.UI.Input.GamepadKeyRoutingConfiguration.IsSupported()) + // contract than WinUI's minimum OS). + if (Windows.Foundation.Metadata.ApiInformation.IsApiContractPresent("Windows.Foundation.UniversalApiContract", 19)) { Windows.UI.Input.GamepadKeyRoutingConfiguration.TrySetKeyRoutingEnabled(false); } @@ -160,10 +194,9 @@ App::App() { // Opt out of gamepad-to-key routing; the framework enabled it during XAML init. // IsApiContractPresent guards OS versions without the API (the type ships in a newer - // contract than WinUI's minimum OS); IsSupported guards devices where it's unusable. + // contract than WinUI's minimum OS). if (winrt::Windows::Foundation::Metadata::ApiInformation::IsApiContractPresent( - L"Windows.Foundation.UniversalApiContract", 19) && - winrt::Windows::UI::Input::GamepadKeyRoutingConfiguration::IsSupported()) + L"Windows.Foundation.UniversalApiContract", 19)) { winrt::Windows::UI::Input::GamepadKeyRoutingConfiguration::TrySetKeyRoutingEnabled(false); } @@ -172,13 +205,30 @@ App::App() # API Pages -_Spec note: This feature introduces no new WinUI API. It calls the existing OS API -[`Windows.UI.Input.GamepadKeyRoutingConfiguration`](https://learn.microsoft.com/uwp/api/windows.ui.input.gamepadkeyroutingconfiguration). -The existing surface is listed in [API Details](#api-details) for reviewer convenience._ +_Spec note: This feature introduces no new WinUI runtime class. It adds one value to the +existing `Microsoft.UI.Xaml.XamlChangeId` enum, and calls the existing OS API +[`Windows.UI.Input.GamepadKeyRoutingConfiguration`](https://learn.microsoft.com/uwp/api/windows.ui.input.gamepadkeyroutingconfiguration)._ # API Details ```c# (but really MIDL3) +namespace Microsoft.UI.Xaml +{ + enum XamlChangeId + { + _Reserved, + IconNoGridOptimization = 61276805, + OptimizeApplyStyles = 61697456, + DefaultStyleOptimizations = 60995620, + DeferContextFlyoutInit = 61098986, + + // New in this work. Experimental until it completes API review. + [feature(Feature_ExperimentalApi)] + [contract(Microsoft.UI.Xaml.WinUIContract, 12)] + GamepadKeyRouting = 63117108, + }; +} + namespace Windows.UI.Input { // Existing OS API (Windows.Foundation.UniversalApiContract, version 19). @@ -206,8 +256,9 @@ namespace Windows.UI.Input The enable is placed at the **full-app entry point**: `Application.Start()`, whose implementation is `FrameworkApplicationFactory::StartImpl` in `/dxaml/xcp/dxaml/lib/FrameworkApplication_Partial.cpp`. `StartImpl` reads the app's -windowing model and dispatches to `StartDesktop()` (WinUI Desktop) or `StartUWP()` (WinUI -UWP). A full WinUI app reaches this method from its generated entry point; a process that +windowing model and dispatches to `StartDesktop()` (WinUI Desktop) or `StartUWP()` (retained +only for tests that have not been ported; WinUI does not support the UWP windowing model). +A full WinUI app reaches this method from its generated entry point; a process that only embeds WinUI as a XAML Island never does. Because the enable lives here, only full WinUI apps turn routing on — anything that starts XAML without going through `Application.Start()` leaves the process-wide setting untouched. @@ -229,25 +280,18 @@ _Check_return_ HRESULT FrameworkApplicationFactory::StartImpl(_In_opt_ xaml::IAp IFC_RETURN(E_FAIL); } - if (policy == AppPolicyWindowingModel_ClassicDesktop || - policy == AppPolicyWindowingModel_Universal) - { - // Full WinUI app: enable gamepad-to-key routing for the process. Island hosts never - // reach here (they start via WindowsXamlManager.InitializeForCurrentThread()), so the - // host process is left untouched. - // IsApiContractPresent: the OS has the type (it ships in UniversalApiContract 19, - // newer than WinUI's minimum OS, so on downlevel OS the type is absent and even - // calling IsSupported() would throw). IsSupported: usable on this device. - if (winrt::Windows::Foundation::Metadata::ApiInformation::IsApiContractPresent( - L"Windows.Foundation.UniversalApiContract", 19) && - winrt::Windows::UI::Input::GamepadKeyRoutingConfiguration::IsSupported()) - { - winrt::Windows::UI::Input::GamepadKeyRoutingConfiguration::TrySetKeyRoutingEnabled(true); - } - } - if (policy == AppPolicyWindowingModel_ClassicDesktop) { + if (OptionalChangeState::IsGamepadKeyRoutingEnabled()) + { + // Full WinUI app: enable gamepad-to-key routing for the process, so XAML's + // keyboard navigation responds to a gamepad the way it did in System XAML. + // Island hosts never reach here (they start via + // WindowsXamlManager.InitializeForCurrentThread()), so the host process is + // left untouched. + VERIFYHR(EnableGamepadKeyRouting()); + } + return FrameworkApplication::StartDesktop(); } else if (policy == AppPolicyWindowingModel_Universal) @@ -259,25 +303,42 @@ _Check_return_ HRESULT FrameworkApplicationFactory::StartImpl(_In_opt_ xaml::IAp } ``` -`ApiInformation::IsApiContractPresent(..., 19)` guards OS versions that predate the API -(where the type isn't registered and calling any member — including `IsSupported()` — would -throw), and `IsSupported()` guards devices where the feature exists but is unusable. +## Why the call lives in a light-up translation unit -`FrameworkApplication_Partial.cpp` will need `#include ` and -`#include `. +`GamepadKeyRoutingConfiguration` requires `UniversalApiContract` 19, but WinUI normally +compiles against contract 7 (Windows build 17763, its downlevel limit), so the type is not +even declared in a normal translation unit. `EnableGamepadKeyRouting()` therefore lives in +`DXamlCoreLightup.cpp`, which is compiled without the PCH and with +`WINDOWS_FOUNDATION_UNIVERSALAPICONTRACT_VERSION` undefined so the SDK headers expose their +latest contract. It is declared in `GamepadKeyRoutingLightup.h`, which `StartImpl` includes. +A `static_assert` in that file fails the build if the SDK in use predates contract 19. + +At run time the OS may still be older than the SDK, so the implementation checks +`ApiInformation::IsApiContractPresentByMajor("Windows.Foundation.UniversalApiContract", 19)` +before activating the type. It does **not** call `IsSupported()` first: +`TrySetKeyRoutingEnabled` always returns `S_OK`, reporting refusal through its out-parameter, +and internally gates on the same feature state that `IsSupported()` reports. A refusal is not +treated as an error — gamepad navigation is an enhancement, and the app is fully usable +without it. ## Startup ordering (why app opt-out works) On the desktop path the sequence is: -1. `Application.Start()` → `StartImpl` — the framework's `TrySetKeyRoutingEnabled(true)` runs - here, before dispatching to `StartDesktop()`. -2. `StartDesktop()` brings up the framework. -3. Construct the app's `Application` object (`App` ctor). -4. `OnLaunched` → app creates its first window. -5. Run the message loop. +1. The generated entry point calls `XamlOptionalChanges.EnableChange(GamepadKeyRouting)` (if + the app opted in) — this must happen before the next step. +2. `Application.Start()` → `StartImpl` — if the optional change is enabled, the framework's + `TrySetKeyRoutingEnabled(true)` runs here, before dispatching to `StartDesktop()`. +3. `StartDesktop()` brings up the framework, which locks optional changes during + `DXamlCore::Initialize()`. +4. Construct the app's `Application` object (`App` ctor). +5. `OnLaunched` → app creates its first window. +6. Run the message loop. -The framework's enable (step 1) runs before any app code (step 3 onward) and never runs +The framework's enable (step 2) runs before any app code (step 4 onward) and never runs again. So to turn gamepad navigation off, an app just calls the setter with `false` from its `App` constructor or `OnLaunched` — that later write is the last one and sticks for the -process. +process. Note that by step 4 the optional changes are already locked, so opting out at that +point means calling `GamepadKeyRoutingConfiguration.TrySetKeyRoutingEnabled(false)` directly; +calling `XamlOptionalChanges.DisableChange()` would both fail and, even if it succeeded, have +no effect on the OS setting that was already applied. diff --git a/dxaml/test/infra/client/lib/WindowHelper.cpp b/dxaml/test/infra/client/lib/WindowHelper.cpp index e61b2599d..03dd5a06b 100644 --- a/dxaml/test/infra/client/lib/WindowHelper.cpp +++ b/dxaml/test/infra/client/lib/WindowHelper.cpp @@ -2117,6 +2117,10 @@ std::vector> GetXamlOptionalChanges { changeId = xaml_settings::XamlChangeId_DeferContextFlyoutInit; } + else if (_wcsicmp(name.c_str(), L"GamepadKeyRouting") == 0) + { + changeId = xaml_settings::XamlChangeId_GamepadKeyRouting; + } if (changeId == xaml_settings::XamlChangeId__Reserved) { @@ -2197,6 +2201,7 @@ void WindowHelper::InitializeXamlCore(_In_ xaml_markup::IXamlMetadataProvider* c optionalChangesStatics->EnableChange(xaml_settings::XamlChangeId_OptimizeApplyStyles, &mutated); optionalChangesStatics->EnableChange(xaml_settings::XamlChangeId_DefaultStyleOptimizations, &mutated); optionalChangesStatics->EnableChange(xaml_settings::XamlChangeId_DeferContextFlyoutInit, &mutated); + optionalChangesStatics->EnableChange(xaml_settings::XamlChangeId_GamepadKeyRouting, &mutated); // Apply per-test overrides from XamlOptionalChanges test data. for (const auto& [changeId, enabled] : changeOverrides) diff --git a/dxaml/xcp/components/metadata/inc/EnumDefs.g.h b/dxaml/xcp/components/metadata/inc/EnumDefs.g.h index 4307689a6..bf02f16a5 100644 --- a/dxaml/xcp/components/metadata/inc/EnumDefs.g.h +++ b/dxaml/xcp/components/metadata/inc/EnumDefs.g.h @@ -2212,6 +2212,7 @@ namespace DirectUI OptimizeApplyStyles = 61697456, DefaultStyleOptimizations = 60995620, DeferContextFlyoutInit = 61098986, + GamepadKeyRouting = 63117108, }; DEFINE_ENUM_FLAG_OPERATORS(XamlChangeId); diff --git a/dxaml/xcp/components/runtimeEnabledFeatures/inc/OptionalChangeState.h b/dxaml/xcp/components/runtimeEnabledFeatures/inc/OptionalChangeState.h index 767cf05ee..e44466256 100644 --- a/dxaml/xcp/components/runtimeEnabledFeatures/inc/OptionalChangeState.h +++ b/dxaml/xcp/components/runtimeEnabledFeatures/inc/OptionalChangeState.h @@ -28,6 +28,7 @@ namespace OptionalChangeState constexpr int BitIndex_OptimizeApplyStyles = 1; constexpr int BitIndex_DefaultStyleOptimizations = 2; constexpr int BitIndex_DeferContextFlyoutInit = 3; + constexpr int BitIndex_GamepadKeyRouting = 4; inline bool IsOptionalChangeEnabled(int bitIndex) { @@ -54,4 +55,9 @@ namespace OptionalChangeState { return IsOptionalChangeEnabled(BitIndex_DeferContextFlyoutInit) || IsPerfOptInEnabled(); } + + inline bool IsGamepadKeyRoutingEnabled() + { + return IsOptionalChangeEnabled(BitIndex_GamepadKeyRouting); + } } diff --git a/dxaml/xcp/dxaml/idl/winrt/core/microsoft.ui.xaml.coretypes2.idl b/dxaml/xcp/dxaml/idl/winrt/core/microsoft.ui.xaml.coretypes2.idl index 9e115db6a..91751262b 100644 --- a/dxaml/xcp/dxaml/idl/winrt/core/microsoft.ui.xaml.coretypes2.idl +++ b/dxaml/xcp/dxaml/idl/winrt/core/microsoft.ui.xaml.coretypes2.idl @@ -520,6 +520,9 @@ namespace Microsoft.UI.Xaml.Settings OptimizeApplyStyles = 61697456, DefaultStyleOptimizations = 60995620, DeferContextFlyoutInit = 61098986, + [feature(Feature_ExperimentalApi)] + [contract(Microsoft.UI.Xaml.WinUIContract, 12)] + GamepadKeyRouting = 63117108, }; } @@ -2364,4 +2367,3 @@ namespace Microsoft.UI.Xaml.Settings }; } - diff --git a/dxaml/xcp/dxaml/lib/DXamlCoreLightup.cpp b/dxaml/xcp/dxaml/lib/DXamlCoreLightup.cpp index 813cac94f..1e1d8b7b4 100644 --- a/dxaml/xcp/dxaml/lib/DXamlCoreLightup.cpp +++ b/dxaml/xcp/dxaml/lib/DXamlCoreLightup.cpp @@ -12,6 +12,9 @@ #include "DXamlCore.h" #include #include "Callback.h" +#include "GamepadKeyRoutingLightup.h" +#include +#include using namespace WRLHelper; using namespace DirectUI; @@ -131,3 +134,40 @@ void DXamlCore::RemoveAnimationsEnabledChangedHandler() m_animationsEnabledChangedToken.value = 0; } } + +// GamepadKeyRoutingConfiguration requires UniversalApiContract 19 (Windows 11, version 24H2). +static_assert( + WINDOWS_FOUNDATION_UNIVERSALAPICONTRACT_VERSION >= 0x130000, + "The Windows SDK in use predates UniversalApiContract 19 and does not declare " + "Windows.UI.Input.GamepadKeyRoutingConfiguration. Update the SDK version this repo builds against."); + +_Check_return_ HRESULT DirectUI::EnableGamepadKeyRouting() +{ + ctl::ComPtr apiInformationStatics; + IFC_RETURN(ctl::GetActivationFactory( + wrl_wrappers::HStringReference(RuntimeClass_Windows_Foundation_Metadata_ApiInformation).Get(), + &apiInformationStatics)); + + boolean isContractPresent = false; + IFC_RETURN(apiInformationStatics->IsApiContractPresentByMajor( + wrl_wrappers::HStringReference(L"Windows.Foundation.UniversalApiContract").Get(), + 19, + &isContractPresent)); + + if (!isContractPresent) + { + return S_OK; + } + + ctl::ComPtr gamepadKeyRoutingStatics; + IFC_RETURN(ctl::GetActivationFactory( + wrl_wrappers::HStringReference(RuntimeClass_Windows_UI_Input_GamepadKeyRoutingConfiguration).Get(), + &gamepadKeyRoutingStatics)); + + // A refusal is not an error: gamepad navigation is an enhancement, and the app remains fully + // usable without it. + boolean wasApplied = false; + IFC_RETURN(gamepadKeyRoutingStatics->TrySetKeyRoutingEnabled(true, &wasApplied)); + + return S_OK; +} diff --git a/dxaml/xcp/dxaml/lib/FrameworkApplication_Partial.cpp b/dxaml/xcp/dxaml/lib/FrameworkApplication_Partial.cpp index d92beb2ae..b58e2a9ad 100644 --- a/dxaml/xcp/dxaml/lib/FrameworkApplication_Partial.cpp +++ b/dxaml/xcp/dxaml/lib/FrameworkApplication_Partial.cpp @@ -26,6 +26,8 @@ #include #include #include +#include "GamepadKeyRoutingLightup.h" +#include using namespace RuntimeFeatureBehavior; using namespace DirectUI; @@ -180,6 +182,16 @@ _Check_return_ HRESULT FrameworkApplicationFactory::StartImpl(_In_opt_ xaml::IAp if (policy == AppPolicyWindowingModel_ClassicDesktop) { + if (OptionalChangeState::IsGamepadKeyRoutingEnabled()) + { + // Full WinUI app: enable gamepad-to-key routing for the process, so XAML's + // keyboard navigation responds to a gamepad the way it did in System XAML. + // Island hosts never reach here (they start via + // WindowsXamlManager.InitializeForCurrentThread()), so the host process is + // left untouched. + VERIFYHR(EnableGamepadKeyRouting()); + } + return FrameworkApplication::StartDesktop(); } else if (policy == AppPolicyWindowingModel_Universal) diff --git a/dxaml/xcp/dxaml/lib/GamepadKeyRoutingLightup.h b/dxaml/xcp/dxaml/lib/GamepadKeyRoutingLightup.h new file mode 100644 index 000000000..9d3273f87 --- /dev/null +++ b/dxaml/xcp/dxaml/lib/GamepadKeyRoutingLightup.h @@ -0,0 +1,21 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. See LICENSE in the project root for license information. + +#pragma once + +namespace DirectUI +{ + // Enables routing of gamepad input to this process as keyboard virtual keys, so that XAML's + // existing keyboard-driven navigation (XY focus, Enter/Space to invoke, Esc to dismiss) responds + // to a gamepad without any app-specific code. See docs\design-notes\gamepad-navigation.md. + // + // The underlying OS setting is process-wide, so this is called only for a full WinUI app, from + // Application.Start(). A process that embeds XAML only as an island never reaches that call and + // is deliberately left at the OS default. An app opts back out by calling + // Windows.UI.Input.GamepadKeyRoutingConfiguration.TrySetKeyRoutingEnabled(false) any time after + // Application.Start(). + // + // Defined in a light-up translation unit because the OS API requires a newer UAP contract than + // the one this build normally targets. + _Check_return_ HRESULT EnableGamepadKeyRouting(); +} diff --git a/dxaml/xcp/dxaml/lib/XamlOptionalChanges_Partial.cpp b/dxaml/xcp/dxaml/lib/XamlOptionalChanges_Partial.cpp index 8dc19a31c..01b8070a3 100644 --- a/dxaml/xcp/dxaml/lib/XamlOptionalChanges_Partial.cpp +++ b/dxaml/xcp/dxaml/lib/XamlOptionalChanges_Partial.cpp @@ -44,6 +44,8 @@ static int GetBitIndex(xaml_settings::XamlChangeId id) return OptionalChangeState::BitIndex_DefaultStyleOptimizations; case xaml_settings::XamlChangeId_DeferContextFlyoutInit: return OptionalChangeState::BitIndex_DeferContextFlyoutInit; + case xaml_settings::XamlChangeId_GamepadKeyRouting: + return OptionalChangeState::BitIndex_GamepadKeyRouting; default: return -1; } diff --git a/dxaml/xcp/tools/XCPTypesAutoGen/XamlOM/Model/Microsoft.UI.Xaml.Settings.cs b/dxaml/xcp/tools/XCPTypesAutoGen/XamlOM/Model/Microsoft.UI.Xaml.Settings.cs index b9c2f9620..45b8d7685 100644 --- a/dxaml/xcp/tools/XCPTypesAutoGen/XamlOM/Model/Microsoft.UI.Xaml.Settings.cs +++ b/dxaml/xcp/tools/XCPTypesAutoGen/XamlOM/Model/Microsoft.UI.Xaml.Settings.cs @@ -9,6 +9,7 @@ namespace Microsoft.UI.Xaml.Settings // through XamlOptionalChanges. Each value corresponds to a specific feature, // fix, or behavioral change documented in the WinUI release notes. [Contract(typeof(Microsoft.UI.Xaml.WinUIContract), 11)] + [Platform("Feature_ExperimentalApi", typeof(Microsoft.UI.Xaml.WinUIContract), Microsoft.UI.Xaml.WinUIContract.Experimental)] [DXamlIdlGroup("coretypes2")] [TypeTable(IsExcludedFromDXaml = true, IsExcludedFromCore = true)] public enum XamlChangeId @@ -20,6 +21,8 @@ namespace Microsoft.UI.Xaml.Settings OptimizeApplyStyles = 61697456, DefaultStyleOptimizations = 60995620, DeferContextFlyoutInit = 61098986, + [VelocityFeature("Feature_ExperimentalApi")] + GamepadKeyRouting = 63117108, } // Provides static methods to opt in to or out of individual breaking or diff --git a/packages.arm64.config b/packages.arm64.config index 63bde295e..9316333e7 100644 --- a/packages.arm64.config +++ b/packages.arm64.config @@ -1,4 +1,4 @@  - + diff --git a/packages.arm64ec.config b/packages.arm64ec.config index 61835153f..af88eeb95 100644 --- a/packages.arm64ec.config +++ b/packages.arm64ec.config @@ -1,4 +1,4 @@ - + diff --git a/packages.config b/packages.config index e955ab738..b0b79c2b9 100644 --- a/packages.config +++ b/packages.config @@ -5,7 +5,7 @@ - + diff --git a/packages.x64.config b/packages.x64.config index 288cc7062..fbd272f23 100644 --- a/packages.x64.config +++ b/packages.x64.config @@ -1,4 +1,4 @@  - + diff --git a/packages.x86.config b/packages.x86.config index fee03ce48..9ea991f75 100644 --- a/packages.x86.config +++ b/packages.x86.config @@ -1,4 +1,4 @@ - +