diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index d58d5ef..b7fc2f2 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -4,11 +4,13 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ## [Unreleased] ### What's new +- Controller shortcuts of your choice: the button that opens Console Mode, the session menu's and the one that goes back to the PC are now set in Settings (hold the buttons you want and let go on the same controller). Capture never combines buttons from different controllers. None is on by default: the first time you open this version the app shows the setup, with a suggestion for each action (Home, Select + Y and Start + Select). One button can't serve two actions. If you used the Home button or Start + Select before, choose them again. (#116) - Settings → "Receive test versions (alpha and beta)": anyone can join the tests of upcoming versions from the app. Off by default; turning it on shows a warning that test versions can have bugs. ### Fixes - Steam: when you go back to the PC (button, menu, shortcut, `stop` link or leaving Big Picture) Big Picture is always closed and Steam is asked to quit the normal way, its own Exit, without killing the process. Before, it stayed open and was often left glitchy. It does not close Steam if a game is running, and you can turn it off in Settings → "Close Steam when going back to the PC". (#121) - Session menu: the notice that shows the shortcut when a session starts no longer gets stuck on screen; it goes away by itself after a few seconds. (#114) +- Controllers: a controller that sends impossible readings through the generic HID driver (many buttons down at once, a stuck D-pad) is now ignored even when Windows also exposes it through Windows.Gaming.Input. The log says so, and reopening the app tries again. (#115) - Displays: a monitor turned to portrait (rotated) keeps its rotation in the layout backup, so restoring the desk puts it back in portrait. Before, the rotation was never saved or restored. (#108) - Playnite: a slow start no longer sends you back to the PC after a few seconds. When Playnite swaps its loading window for the main one, Console Mode now follows the new window and only restores the desk once Playnite actually closes. (#83) - Audio: if switching the output fails, the session goes on instead of being rolled back after the screens were already switched. (#109) diff --git a/CHANGELOG.md b/CHANGELOG.md index 103bf90..de51adb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,11 +4,13 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ## [Unreleased] ### Novidades +- Atalhos do controle à sua escolha: o botão que abre o Console Mode, o do menu da sessão e o de voltar ao PC agora são configuráveis em Ajustes (basta segurar os botões que você quer e soltar no mesmo controle). A captura não mistura botões de controles diferentes. Nenhum vem ligado: na primeira abertura desta versão o app mostra a configuração, com uma sugestão para cada ação (Home, Select + Y e Start + Select). Um mesmo botão não pode servir para duas ações. Quem usava o botão Home ou Start + Select precisa escolher de novo. (#116) - Ajustes → "Receber versões de teste (alpha e beta)": qualquer pessoa pode entrar nos testes das próximas versões pelo próprio app. Desligado por padrão; ao ligar, o app avisa que a versão pode ter bugs. ### Correções - Steam: ao voltar ao PC (botão, menu, atalho, link `stop` ou saindo do Big Picture) o Big Picture é sempre fechado e a Steam é chamada para fechar do jeito normal, o "Sair" dela, sem encerrar o processo à força. Antes ela ficava aberta e costumava ficar bugada. Não fecha a Steam se houver um jogo rodando, e dá para desligar em Ajustes → "Fechar a Steam ao voltar ao PC". (#121) - Menu da sessão: o aviso que mostra o atalho no início da sessão não fica mais preso na tela; ele some sozinho depois de alguns segundos. (#114) +- Controles: um controle que manda leituras impossíveis pelo driver HID genérico (muitos botões apertados ao mesmo tempo, direcional preso) agora é ignorado também quando o Windows o expõe por Windows.Gaming.Input. O log avisa, e reabrir o app tenta de novo. (#115) - Telas: um monitor girado (em retrato) mantém a rotação no backup do layout, então restaurar a mesa devolve o monitor em retrato. Antes a rotação nunca era salva nem restaurada. (#108) - Playnite: quando ele demora para abrir, o app não volta mais para o PC depois de alguns segundos. Se o Playnite troca a janela de carregamento pela principal, o Console Mode passa a acompanhar a nova e só restaura a mesa quando o Playnite fecha de verdade. (#83) - Áudio: se a troca da saída falhar, a sessão continua em vez de ser desfeita depois de as telas já terem sido trocadas. (#109) diff --git a/README.md b/README.md index 5bd5f88..adc3967 100644 --- a/README.md +++ b/README.md @@ -29,13 +29,15 @@ Windows 10 or 11, plus [Steam](https://store.steampowered.com/) (Big Picture), [ 2. Press **Play now**, or use the one-click desktop shortcut. The first time on a new screen, the TV asks "Can you see this screen?" and reverts on its own if nobody answers. 3. Quit Big Picture or Playnite and everything goes back: screens, audio, resolution. -Prefer the couch? With the app in the tray, hold the controller's Home button and it starts from there. +Prefer the couch? With the app in the tray, hold your controller shortcut and it starts from there. -| Controller shortcut | Does | -|---|---| -| Hold **Home** (Xbox / PS) | Enter console mode from the tray | -| Hold **Select + Y** (Create + △) | Menu over the game: volume, resolution, audio, FPS, HDR, exit | -| Hold **Start + Select** (Options + Create) | Back to the PC | +You pick the controller shortcuts yourself: none is on at first, the app asks the first time you open it (and you can change them in Settings). No button can serve two actions. + +| Controller shortcut | Does | Suggested | +|---|---|---| +| Hold your **open** shortcut | Enter console mode from the tray | **Home** (Xbox / PS) | +| Hold your **menu** shortcut | Menu over the game: volume, resolution, audio, FPS, HDR, exit | **Select + Y** (Create + △) | +| Hold your **back to the PC** shortcut | Back to the PC | **Start + Select** (Options + Create) | ## Features diff --git a/README.pt-BR.md b/README.pt-BR.md index e8df42d..52bc716 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -29,13 +29,15 @@ Windows 10 ou 11, com [Steam](https://store.steampowered.com/) (Big Picture), [P 2. Clique em **Jogar agora**, ou use o atalho de 1 clique na Área de Trabalho. Na primeira vez numa tela nova, a TV pergunta "Está vendo esta tela?" e desfaz tudo sozinha se ninguém responder. 3. Saia do Big Picture ou do Playnite e tudo volta: telas, áudio, resolução. -Prefere o sofá? Com o app na bandeja, segure o botão Home do controle e ele começa dali. +Prefere o sofá? Com o app na bandeja, segure o seu atalho do controle e ele começa dali. -| Atalho no controle | Faz | -|---|---| -| Segurar **Home** (Xbox / PS) | Entra no modo console a partir da bandeja | -| Segurar **Select + Y** (Create + △) | Menu sobre o jogo: volume, resolução, áudio, FPS, HDR, sair | -| Segurar **Start + Select** (Options + Create) | Volta para o PC | +Os atalhos do controle são você quem escolhe: nenhum vem ligado, o app pergunta na primeira abertura (e dá para mudar em Ajustes). Nenhum botão pode servir para duas ações. + +| Atalho no controle | Faz | Sugestão | +|---|---|---| +| Segurar o seu atalho de **abrir** | Entra no modo console a partir da bandeja | **Home** (Xbox / PS) | +| Segurar o seu atalho do **menu** | Menu sobre o jogo: volume, resolução, áudio, FPS, HDR, sair | **Select + Y** (Create + △) | +| Segurar o seu atalho de **voltar ao PC** | Volta para o PC | **Start + Select** (Options + Create) | ## Recursos diff --git a/docs/TESTING.md b/docs/TESTING.md index 57793ba..582a325 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -4,11 +4,19 @@ Tudo aqui precisa de hardware ou de olho humano e **não é coberto pelos testes Como testar em build de desenvolvimento: `dotnet build src/ConsoleMode -c Debug -r win-x64` e rode `src/ConsoleMode/bin/Debug/net8.0-windows10.0.19041.0/win-x64/ConsoleMode.exe`. Os dados ficam em `ConsoleMode_Data` ao lado do exe (`config.json`, `consolemode.log`). -## 1. Botão Home do controle (PR #27) +## 0. Atalhos do controle configuráveis e primeira configuração Pré-condições: controle **Xbox** (XInput) conectado; app na bandeja (minimizado ou aberto com `--tray`); sessão inativa. - [ ] **Segurar o botão Xbox por 1 s** entra no modo console (mesmo caminho do atalho "1 Click"). Resultado: ______ +- [ ] Ao configurar um atalho, deixe um controle segurando um botão e use outro para montar o atalho; a captura aceita o segundo sem esperar o primeiro soltar. Resultado: ______ +- [ ] Com dois controles conectados, pressione partes diferentes do atalho em cada um, inclusive soltando um antes de pressionar o outro; a captura não combina os controles e só aceita a combinação feita em um único controle. Resultado: ______ +- [ ] Durante a captura, desconecte o controle depois de pressionar parte da combinação e continue no segundo controle; o primeiro encerra sua captura e os botões do segundo não são acrescentados a ela. Resultado: ______ +## 1. Atalho Home do controle (PR #27) + +Pré-condições: atalho de "Abrir o Console Mode" definido (o padrão sugerido é o botão Xbox, controle **Xbox** (XInput)); app na bandeja (minimizado ou aberto com `--tray`); sessão inativa. + +- [ ] **Segurar o atalho por 1 s** entra no modo console (mesmo caminho do atalho "1 Click"). Resultado: ______ - [ ] **Toque curto** no botão Xbox não faz nada no app (só a Game Bar abre, se o atalho dela estiver ligado). Resultado: ______ - [ ] Com a sessão **ativa** (Big Picture aberto), segurar o botão Xbox **não** reinicia nada. Resultado: ______ - [ ] **Segurar Start + Select por 1 s** durante a sessão restaura a mesa (igual a "Restaurar agora"). Resultado: ______ @@ -35,7 +43,7 @@ Pré-condições: Ajustes → "Entrar ao conectar um controle" **ligado**; app n - [ ] O log mostra `Controle conectado: ; auto-start sim/não` a cada conexão. Resultado: ______ - [ ] Com a opção **desligada** (padrão), nada disso acontece. Resultado: ______ -## 1c. Menu da sessão (Select + Y) +## 1c. Menu da sessão (atalho sugerido: Select + Y) Pré-condições: sessão ativa com Big Picture (ou jogo borderless) na tela; controle Xbox ou PlayStation. @@ -105,6 +113,8 @@ Pré-condições: Ajustes → Interface = **Automático** (padrão). - [ ] Com o **Steam aberto** e suporte a PlayStation ligado: se a leitura fica vazia, fechar o Steam faz voltar (é o Steam Input capturando o controle). Resultado: ______ - [ ] "Copiar diagnóstico" cola dispositivos + amostra; o `consolemode.log` tem a linha `Controles: …` de abertura com o DualSense listado. Resultado: ______ - [ ] Desconectar e reconectar durante o teste: a lista atualiza e a leitura continua. Resultado: ______ +- [ ] Um controle HID que manda leitura impossível (ex.: Switch Pro pelo driver genérico, com 6 ou mais botões "apertados" sozinhos) é ignorado mesmo quando também aparece em Windows.Gaming.Input: o app não navega nem inicia a sessão sozinho, e o log mostra `Controles: … ignorado: leitura inválida`. Reabrir o app tenta de novo. Resultado: ______ +- [ ] Com um controle HID defeituoso e outro controle normal conectados, o defeituoso não gera ações pelo HID nem pela projeção Windows.Gaming.Input, e o controle normal continua navegando. Resultado: ______ ## 3c. Fechar a Steam ao voltar ao PC diff --git a/src/ConsoleMode/App.xaml.cs b/src/ConsoleMode/App.xaml.cs index a00e973..582cf2d 100644 --- a/src/ConsoleMode/App.xaml.cs +++ b/src/ConsoleMode/App.xaml.cs @@ -153,28 +153,28 @@ private async Task HandleStartRequestAsync() } /// - /// Idle: the Home button enters console mode (a hold, or a press once Game Bar's shortcut - /// is off). In a session Big Picture owns the Home button, so Start + Back held together - /// restores the desk instead. + /// The shortcuts the user picked (Settings, or the first-run setup); none is set until then. + /// Home, from the tray: enter console mode (a hold, or a press once Game Bar's shortcut is + /// off). Menu and Exit, in a session: the menu over the game, and back to the PC. /// private void WatchGuideButton() { if (_window is null || ViewModel is null) return; - _guide = new ControllerHoldWatcher(_window.DispatcherQueue, ControllerHoldWatcher.GuideButton, "botão Home"); + _guide = new ControllerHoldWatcher(_window.DispatcherQueue, 0, "atalho Home"); _guide.Held += () => { if (ViewModel is not null) ViewModel.LaunchedByController = true; _ = HandleStartRequestAsync(); }; - _exitChord = new ControllerHoldWatcher(_window.DispatcherQueue, (ushort)(ControllerHoldWatcher.StartButton | ControllerHoldWatcher.BackButton), "Start + Back"); + _exitChord = new ControllerHoldWatcher(_window.DispatcherQueue, 0, "atalho Voltar ao PC"); _exitChord.Held += () => { if (ViewModel?.IsConsoleActive == true) _ = ViewModel.RestoreNowAsync(); }; - // Select + Y: the in-session menu (volume, resolution, audio, FPS, HDR, leave). - _menuChord = new ControllerHoldWatcher(_window.DispatcherQueue, (ushort)(ControllerHoldWatcher.BackButton | ControllerHoldWatcher.YButton), "Select + Y") + _menuChord = new ControllerHoldWatcher(_window.DispatcherQueue, 0, "atalho Menu da sessão") { HoldDuration = TimeSpan.FromMilliseconds(250) }; _menuChord.Held += () => ViewModel?.ToggleSessionMenu(); ViewModel.PropertyChanged += OnViewModelChangedForGuide; + ViewModel.ShortcutOnboardingRequested += () => _ = ShowShortcutOnboardingAsync(); RefreshGuideWatch(); // Issue #29: a pad connecting while we're in the tray means "I'm on the couch". @@ -192,7 +192,7 @@ private void WatchGuideButton() private void OnViewModelChangedForGuide(object? sender, PropertyChangedEventArgs e) { - if (e.PropertyName is nameof(MainViewModel.HomeButtonLaunch) or nameof(MainViewModel.HomeButtonShortPress) + if (e.PropertyName is MainViewModel.ShortcutsProperty or nameof(MainViewModel.HomeButtonShortPress) or nameof(MainViewModel.IsConsoleActive)) RefreshGuideWatch(); if (e.PropertyName == nameof(MainViewModel.IsConsoleActive) && ViewModel?.IsConsoleActive == false) @@ -202,12 +202,43 @@ or nameof(MainViewModel.IsConsoleActive)) private void RefreshGuideWatch() { if (_guide is null || _exitChord is null || _menuChord is null || ViewModel is null) return; + _guide.Mask = ViewModel.GetShortcut(ShortcutSlot.Home); + _menuChord.Mask = ViewModel.GetShortcut(ShortcutSlot.Menu); + _exitChord.Mask = ViewModel.GetShortcut(ShortcutSlot.Exit); _guide.HoldDuration = ViewModel.HomeButtonShortPress ? TimeSpan.Zero : ControllerHoldWatcher.LongHold; - // The session chords (Select + Y, Start + Back) do not depend on the Home button option: - // turning that off used to leave them dead too. - if (ViewModel.IsConsoleActive) { _exitChord.Start(); _menuChord.Start(); } - else { _exitChord.Stop(); _menuChord.Stop(); } - if (ViewModel.HomeButtonLaunch && !ViewModel.IsConsoleActive) _guide.Start(); - else _guide.Stop(); + + // Picking a shortcut presses buttons on purpose: nothing may fire meanwhile. A shortcut + // that isn't set never runs, and the three do not depend on each other. + var idle = !ViewModel.IsCapturingShortcut; + Apply(_guide, idle && _guide.Mask != 0 && !ViewModel.IsConsoleActive); + Apply(_menuChord, idle && _menuChord.Mask != 0 && ViewModel.IsConsoleActive); + Apply(_exitChord, idle && _exitChord.Mask != 0 && ViewModel.IsConsoleActive); + } + + private bool _onboardingOpen; + + /// The first-run shortcuts setup (nothing is set until the user chooses). + private async Task ShowShortcutOnboardingAsync() + { + if (_onboardingOpen || _window?.Content?.XamlRoot is not { } root || ViewModel is null) return; + _onboardingOpen = true; + try + { + await Controls.ShortcutPicker.ShowOnboardingAsync(root, ViewModel); + } + catch (Exception ex) + { + AppLog.Write($"Atalhos: {ex.Message}"); + } + finally + { + _onboardingOpen = false; + } + } + + private static void Apply(ControllerHoldWatcher watcher, bool run) + { + if (run) watcher.Start(); + else watcher.Stop(); } } diff --git a/src/ConsoleMode/Controls/ShortcutPicker.cs b/src/ConsoleMode/Controls/ShortcutPicker.cs new file mode 100644 index 0000000..bd520a1 --- /dev/null +++ b/src/ConsoleMode/Controls/ShortcutPicker.cs @@ -0,0 +1,175 @@ +using ConsoleMode.Services; +using ConsoleMode.ViewModels; +using Microsoft.UI.Dispatching; +using Microsoft.UI.Xaml; +using Microsoft.UI.Xaml.Controls; +using Microsoft.UI.Xaml.Media; +using Windows.UI; + +namespace ConsoleMode.Controls; + +/// +/// One controller shortcut: what it is now, "Set" to press the buttons you want (they are read +/// live from the pad and recorded when let go) and "Remove". A combo another shortcut already +/// uses, or a single face button, is refused with the reason. Used in Settings and in the +/// first-run setup. +/// +public sealed class ShortcutPicker : StackPanel +{ + private readonly MainViewModel _vm; + private readonly ShortcutSlot _slot; + private readonly TextBlock _value = new() { VerticalAlignment = VerticalAlignment.Center, MinWidth = 120 }; + private readonly TextBlock _error = new() + { + TextWrapping = TextWrapping.Wrap, + MaxWidth = 460, + Foreground = new SolidColorBrush(Color.FromArgb(255, 232, 88, 88)), + Visibility = Visibility.Collapsed + }; + private readonly Button _set = new(); + private readonly Button _clear = new(); + private readonly DispatcherQueueTimer _timer; + private ShortcutCapture? _capture; + + /// In the setup dialog: left-aligned under the text instead of a right-hand column. + public ShortcutPicker(MainViewModel vm, ShortcutSlot slot, bool stacked = false) + { + _vm = vm; + _slot = slot; + Spacing = 4; + var side = stacked ? HorizontalAlignment.Left : HorizontalAlignment.Right; + HorizontalAlignment = side; + + var row = new StackPanel { Orientation = Orientation.Horizontal, Spacing = 8, HorizontalAlignment = side }; + row.Children.Add(_value); + row.Children.Add(_set); + row.Children.Add(_clear); + Children.Add(row); + Children.Add(_error); + + _timer = DispatcherQueue.GetForCurrentThread().CreateTimer(); + _timer.Interval = TimeSpan.FromMilliseconds(40); + _timer.Tick += (_, _) => Poll(); + _set.Click += (_, _) => { if (_capture is null) StartCapture(); else StopCapture(); }; + _clear.Click += (_, _) => { _error.Visibility = Visibility.Collapsed; _vm.ClearShortcut(_slot); }; + + // Subscribed for good, not on Loaded/Unloaded: inside a ContentDialog those events can + // leave the picker unsubscribed and its text stale. The pickers are few and live as long + // as their page or dialog, so there is nothing to leak of note. + _vm.PropertyChanged += OnViewModelChanged; + LocalizationService.LanguageChanged += OnLanguageChanged; + Unloaded += (_, _) => StopCapture(); + Refresh(); + } + + private void OnViewModelChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e) + { + if (e.PropertyName == MainViewModel.ShortcutsProperty) Refresh(); + } + + private void OnLanguageChanged(object? sender, EventArgs e) => DispatcherQueue.TryEnqueue(Refresh); + + private void Refresh() + { + var isSet = _vm.GetShortcut(_slot) != 0; + if (_capture is null) _value.Text = _vm.ShortcutText(_slot); + _set.Content = LocalizationService.Get(_capture is not null ? "ShortcutCancel" : isSet ? "ShortcutChange" : "ShortcutSet"); + _clear.Content = LocalizationService.Get("ShortcutClear"); + _clear.Visibility = isSet && _capture is null ? Visibility.Visible : Visibility.Collapsed; + _value.Opacity = isSet || _capture is not null ? 1 : 0.6; + } + + private void StartCapture() + { + _error.Visibility = Visibility.Collapsed; + _capture = new ShortcutCapture(); + SonyHidReader.Acquire(); + _vm.BeginShortcutCapture(); + _value.Text = LocalizationService.Get("ShortcutPressButtons"); + Refresh(); + _timer.Start(); + } + + private void StopCapture() + { + if (_capture is null) return; + _capture = null; + _timer.Stop(); + SonyHidReader.Release(); + _vm.EndShortcutCapture(); + Refresh(); + } + + private void Poll() + { + if (_capture is null) return; + var held = ControllerHoldWatcher.ReadHeldByDevice(); + var captured = _capture.Feed(held); + var live = ControllerShortcuts.Format(_capture.CurrentHeld, _vm.IsPlayStationHints); + _value.Text = live.Length > 0 ? live : LocalizationService.Get("ShortcutPressButtons"); + if (captured is not { } mask) return; + + StopCapture(); + var error = _vm.TrySetShortcut(_slot, mask); + _error.Text = error ?? ""; + _error.Visibility = error is null ? Visibility.Collapsed : Visibility.Visible; + } + + /// + /// First run of the version that made shortcuts configurable: nothing is set, and the user + /// chooses each one here (or takes the suggestions), or skips and does it later in Settings. + /// + public static async Task ShowOnboardingAsync(XamlRoot root, MainViewModel vm) + { + var panel = new StackPanel { Spacing = 16 }; + panel.Children.Add(new TextBlock + { + Text = LocalizationService.Get("ShortcutsOnboardingBody"), + TextWrapping = TextWrapping.Wrap + }); + + foreach (var (slot, descriptionKey) in new[] + { + (ShortcutSlot.Home, "HomeButtonDescription"), + (ShortcutSlot.Menu, "SessionMenuDescription"), + (ShortcutSlot.Exit, "ExitShortcutDescription") + }) + { + var text = new StackPanel { Spacing = 2 }; + text.Children.Add(new TextBlock { Text = vm.ShortcutName(slot), FontWeight = Microsoft.UI.Text.FontWeights.SemiBold }); + text.Children.Add(new TextBlock + { + Text = LocalizationService.Get(descriptionKey), + TextWrapping = TextWrapping.Wrap, + FontSize = 12, + Opacity = 0.7 + }); + // Stacked: the dialog is narrow, a right-hand column would be clipped. + var row = new StackPanel { Spacing = 8 }; + row.Children.Add(text); + row.Children.Add(new ShortcutPicker(vm, slot, stacked: true)); + panel.Children.Add(row); + } + + var suggest = new Button { Content = LocalizationService.Get("ShortcutsUseSuggested") }; + suggest.Click += (_, _) => vm.UseSuggestedShortcuts(); + panel.Children.Add(suggest); + + var dialog = new ContentDialog + { + XamlRoot = root, + Title = LocalizationService.Get("ShortcutsOnboardingTitle"), + Content = new ScrollViewer { Content = panel }, + CloseButtonText = LocalizationService.Get("ShortcutsOnboardingDone"), + DefaultButton = ContentDialogButton.Close + }; + try + { + await dialog.ShowAsync(); + } + finally + { + vm.CompleteShortcutOnboarding(); + } + } +} diff --git a/src/ConsoleMode/MainWindow.xaml.cs b/src/ConsoleMode/MainWindow.xaml.cs index 6890ade..9f8c8de 100644 --- a/src/ConsoleMode/MainWindow.xaml.cs +++ b/src/ConsoleMode/MainWindow.xaml.cs @@ -75,6 +75,8 @@ private void WatchDesktopPad(nint hwnd) { Intercept = action => { + // Picking a shortcut presses buttons on purpose: they must not also navigate. + if (ViewModel.IsCapturingShortcut) return true; if (ViewModel.TourStep <= 0) return false; if (action == ControllerAction.Confirm) ViewModel.TourNextCommand.Execute(null); else if (action == ControllerAction.Back) ViewModel.EndTourCommand.Execute(null); diff --git a/src/ConsoleMode/Models/Models.cs b/src/ConsoleMode/Models/Models.cs index c6895f6..419556d 100644 --- a/src/ConsoleMode/Models/Models.cs +++ b/src/ConsoleMode/Models/Models.cs @@ -41,8 +41,18 @@ public sealed class AppConfig /// Also offer alpha/beta releases (Settings → Receive test versions). public bool BetaUpdates { get; set; } - /// Holding the Xbox Guide (Home) button while in the tray enters console mode. - public bool HomeButtonLaunch { get; set; } = true; + /// + /// Controller shortcuts the user picked, as XInput button bits (see ControllerShortcuts); + /// 0 = not set, which is how every install starts: nothing fires until they choose. + /// Home: from the tray, enters console mode. Menu: in a session, opens the menu over the + /// game. Exit: in a session, goes back to the PC. + /// + public int HomeShortcut { get; set; } + public int MenuShortcut { get; set; } + public int ExitShortcut { get; set; } + + /// The shortcuts setup (first run of the version that made them configurable) was finished or skipped. + public bool ShortcutsOnboardingDone { get; set; } /// A short press of the Guide button is enough (Game Bar's own shortcut is turned off). public bool HomeButtonShortPress { get; set; } diff --git a/src/ConsoleMode/Resources/Strings.en-US.json b/src/ConsoleMode/Resources/Strings.en-US.json index ca00509..b8eafcc 100644 --- a/src/ConsoleMode/Resources/Strings.en-US.json +++ b/src/ConsoleMode/Resources/Strings.en-US.json @@ -56,7 +56,7 @@ "BackToGame": "Back to the game", "VolumeRow": "Volume", "Muted": "Muted", - "SessionMenuHint": "Select + Y opens this menu at any time.", + "SessionMenuHint": "Your session menu shortcut opens this menu at any time.", "SessionHintTitle": "Console Mode menu", "SessionHintBody": "Hold {0} to open the panel over the game.", "RecordLast30": "Record the last 30 s", @@ -64,8 +64,8 @@ "ExitConsoleMode": "Exit Console Mode", "HintVolume": "Volume", "SessionElapsed": "Playing for {0}", - "SessionMenuCard": "Session menu (Select + Y)", - "SessionMenuDescription": "During a session, hold Select + Y to open a menu over the game: volume, resolution, audio, FPS, HDR, back to the PC or exit. Does not work over exclusive-fullscreen games.", + "SessionMenuCard": "Session menu shortcut", + "SessionMenuDescription": "During a session, hold this shortcut to open a menu over the game: volume, resolution, audio, FPS, HDR, back to the PC or exit. Does not work over exclusive-fullscreen games.", "ConsoleScreensHeading": "Screens", "ConsoleQuickHeading": "Quick settings", "FullSettings": "All settings", @@ -73,10 +73,27 @@ "HintChange": "Change", "ConsoleSettingsHint": "Press A on a row to change its value. Everything is saved right away.", "BackToPc": "Back to the PC", - "ConsoleActiveHint": "Or hold Start + Select on the controller for 1 second.", + "ConsoleActiveHint": "Or hold {0} on the controller for 1 second.", "ChooseRoleFor": "What does the {0} screen do?", - "HomeButtonCard": "Open with the controller Home button", - "HomeButtonDescription": "With the app in the tray, hold the Xbox button for 1 second to enter console mode. During a session, hold Start + Select for 1 second to go back to the PC. Xbox (XInput) controllers only.", + "HomeButtonCard": "Controller shortcut to open Console Mode", + "HomeButtonDescription": "With the app in the tray, hold this shortcut for 1 second to enter console mode (a single press if you turn on the short press below). Works with Xbox and PlayStation controllers.", + "ExitShortcutCard": "Controller shortcut to go back to the PC", + "ExitShortcutDescription": "During a session, hold this shortcut for 1 second to restore your desk and go back to the PC.", + "ShortcutHome": "Open Console Mode", + "ShortcutMenu": "Session menu", + "ShortcutExit": "Back to the PC", + "ShortcutNotSet": "Not set", + "ShortcutSet": "Set", + "ShortcutChange": "Change", + "ShortcutClear": "Remove", + "ShortcutCancel": "Cancel", + "ShortcutPressButtons": "Hold the buttons, then let go…", + "ShortcutNotAllowed": "Use two or more buttons together (like Select + Y), or the Home button alone. A single button would fire in the middle of a game.", + "ShortcutInUse": "{0} is already the shortcut for \"{1}\". Pick a different combination.", + "ShortcutsOnboardingTitle": "Set up your controller shortcuts", + "ShortcutsOnboardingBody": "No shortcut is on until you choose it. Pick a button or combination for each action, or leave it without one. Press Set, hold the buttons and let go. The same combination can't be used for two actions. You can change all of this later in Settings.", + "ShortcutsUseSuggested": "Use the suggested shortcuts", + "ShortcutsOnboardingDone": "Done", "CloseSteamCard": "Close Steam when going back to the PC", "CloseSteamDescription": "When you leave Big Picture or go back to the PC, Steam is asked to quit the normal way (its own Exit, never forced). Big Picture is always closed. Steam stays open if a game is running.", "AutoStartControllerCard": "Enter when a controller connects", diff --git a/src/ConsoleMode/Resources/Strings.es-ES.json b/src/ConsoleMode/Resources/Strings.es-ES.json index 5cc9299..2422402 100644 --- a/src/ConsoleMode/Resources/Strings.es-ES.json +++ b/src/ConsoleMode/Resources/Strings.es-ES.json @@ -56,7 +56,7 @@ "BackToGame": "Volver al juego", "VolumeRow": "Volumen", "Muted": "Silencio", - "SessionMenuHint": "Select + Y abre este menú en cualquier momento.", + "SessionMenuHint": "El atajo del menú de la sesión abre este menú en cualquier momento.", "SessionHintTitle": "Menú de Console Mode", "SessionHintBody": "Mantén {0} para abrir el panel sobre el juego.", "RecordLast30": "Grabar los últimos 30 s", @@ -64,8 +64,8 @@ "ExitConsoleMode": "Salir de Console Mode", "HintVolume": "Volumen", "SessionElapsed": "Jugando desde hace {0}", - "SessionMenuCard": "Menú de la sesión (Select + Y)", - "SessionMenuDescription": "Durante la sesión, mantén Select + Y para abrir un menú sobre el juego: volumen, resolución, audio, FPS, HDR, volver al PC o salir. No funciona sobre juegos en pantalla completa exclusiva.", + "SessionMenuCard": "Atajo del menú de la sesión", + "SessionMenuDescription": "Durante la sesión, mantén este atajo para abrir un menú sobre el juego: volumen, resolución, audio, FPS, HDR, volver al PC o salir. No funciona sobre juegos en pantalla completa exclusiva.", "ConsoleScreensHeading": "Pantallas", "ConsoleQuickHeading": "Ajustes rápidos", "FullSettings": "Todos los ajustes", @@ -73,10 +73,27 @@ "HintChange": "Cambiar", "ConsoleSettingsHint": "Pulsa A en una fila para cambiar su valor. Todo se guarda al momento.", "BackToPc": "Volver al PC", - "ConsoleActiveHint": "O mantén Start + Select en el mando durante 1 segundo.", + "ConsoleActiveHint": "O mantén {0} en el mando durante 1 segundo.", "ChooseRoleFor": "¿Qué hace la pantalla {0}?", - "HomeButtonCard": "Abrir con el botón Home del mando", - "HomeButtonDescription": "Con la aplicación en la bandeja, mantén el botón Xbox 1 segundo para entrar en modo consola. Durante la sesión, mantén Start + Select 1 segundo para volver al PC. Solo mandos Xbox (XInput).", + "HomeButtonCard": "Atajo del mando para abrir Console Mode", + "HomeButtonDescription": "Con la aplicación en la bandeja, mantén este atajo 1 segundo para entrar en modo consola (una sola pulsación si activas la pulsación corta de abajo). Funciona con mandos Xbox y PlayStation.", + "ExitShortcutCard": "Atajo del mando para volver al PC", + "ExitShortcutDescription": "Durante la sesión, mantén este atajo 1 segundo para restaurar el escritorio y volver al PC.", + "ShortcutHome": "Abrir Console Mode", + "ShortcutMenu": "Menú de la sesión", + "ShortcutExit": "Volver al PC", + "ShortcutNotSet": "Sin definir", + "ShortcutSet": "Definir", + "ShortcutChange": "Cambiar", + "ShortcutClear": "Quitar", + "ShortcutCancel": "Cancelar", + "ShortcutPressButtons": "Mantén los botones y luego suéltalos…", + "ShortcutNotAllowed": "Usa dos o más botones a la vez (como Select + Y), o solo el botón Home. Un botón suelto se activaría en mitad del juego.", + "ShortcutInUse": "{0} ya es el atajo de \"{1}\". Elige otra combinación.", + "ShortcutsOnboardingTitle": "Configura los atajos del mando", + "ShortcutsOnboardingBody": "Ningún atajo viene activado: tú eliges. Define un botón o combinación para cada acción, o déjala sin atajo. Pulsa Definir, mantén los botones y suéltalos. La misma combinación no puede servir para dos acciones. Puedes cambiarlo todo después en Ajustes.", + "ShortcutsUseSuggested": "Usar los atajos sugeridos", + "ShortcutsOnboardingDone": "Listo", "CloseSteamCard": "Cerrar Steam al volver al PC", "CloseSteamDescription": "Al salir de Big Picture o volver al PC, se le pide a Steam que se cierre de la forma normal (su propio «Salir», nunca a la fuerza). Big Picture siempre se cierra. Steam sigue abierta si hay un juego en ejecución.", "AutoStartControllerCard": "Entrar al conectar un mando", diff --git a/src/ConsoleMode/Resources/Strings.pt-BR.json b/src/ConsoleMode/Resources/Strings.pt-BR.json index f494586..dd039fd 100644 --- a/src/ConsoleMode/Resources/Strings.pt-BR.json +++ b/src/ConsoleMode/Resources/Strings.pt-BR.json @@ -56,7 +56,7 @@ "BackToGame": "Voltar ao jogo", "VolumeRow": "Volume", "Muted": "Mudo", - "SessionMenuHint": "Select + Y abre este menu a qualquer momento.", + "SessionMenuHint": "O atalho do menu da sessão abre este menu a qualquer momento.", "SessionHintTitle": "Menu do Console Mode", "SessionHintBody": "Segure {0} para abrir o painel sobre o jogo.", "RecordLast30": "Gravar os últimos 30 s", @@ -64,8 +64,8 @@ "ExitConsoleMode": "Sair do Console Mode", "HintVolume": "Volume", "SessionElapsed": "Jogando há {0}", - "SessionMenuCard": "Menu da sessão (Select + Y)", - "SessionMenuDescription": "Durante a sessão, segure Select + Y para abrir um menu sobre o jogo: volume, resolução, áudio, FPS, HDR, voltar ao PC ou sair. Não funciona sobre jogos em tela cheia exclusiva.", + "SessionMenuCard": "Atalho do menu da sessão", + "SessionMenuDescription": "Durante a sessão, segure este atalho para abrir um menu sobre o jogo: volume, resolução, áudio, FPS, HDR, voltar ao PC ou sair. Não funciona sobre jogos em tela cheia exclusiva.", "ConsoleScreensHeading": "Telas", "ConsoleQuickHeading": "Ajustes rápidos", "FullSettings": "Todos os ajustes", @@ -73,10 +73,27 @@ "HintChange": "Alterar", "ConsoleSettingsHint": "Aperte A numa linha para trocar o valor. Tudo é salvo na hora.", "BackToPc": "Voltar ao PC", - "ConsoleActiveHint": "Ou segure Start + Select no controle por 1 segundo.", + "ConsoleActiveHint": "Ou segure {0} no controle por 1 segundo.", "ChooseRoleFor": "O que a tela {0} faz?", - "HomeButtonCard": "Abrir com o botão Home do controle", - "HomeButtonDescription": "Com o app na bandeja, segure o botão Xbox por 1 segundo para entrar no modo console. Durante a sessão, segure Start + Select por 1 segundo para voltar ao PC. Só controles Xbox (XInput).", + "HomeButtonCard": "Atalho do controle para abrir o Console Mode", + "HomeButtonDescription": "Com o app na bandeja, segure este atalho por 1 segundo para entrar no modo console (um toque só, se você ligar o toque curto abaixo). Funciona com controles Xbox e PlayStation.", + "ExitShortcutCard": "Atalho do controle para voltar ao PC", + "ExitShortcutDescription": "Durante a sessão, segure este atalho por 1 segundo para restaurar a mesa e voltar ao PC.", + "ShortcutHome": "Abrir o Console Mode", + "ShortcutMenu": "Menu da sessão", + "ShortcutExit": "Voltar ao PC", + "ShortcutNotSet": "Não definido", + "ShortcutSet": "Definir", + "ShortcutChange": "Alterar", + "ShortcutClear": "Remover", + "ShortcutCancel": "Cancelar", + "ShortcutPressButtons": "Segure os botões e depois solte…", + "ShortcutNotAllowed": "Use dois ou mais botões juntos (como Select + Y), ou só o botão Home. Um botão sozinho dispararia no meio do jogo.", + "ShortcutInUse": "{0} já é o atalho de \"{1}\". Escolha outra combinação.", + "ShortcutsOnboardingTitle": "Configure os atalhos do controle", + "ShortcutsOnboardingBody": "Nenhum atalho vem ligado: você escolhe. Defina um botão ou combinação para cada ação, ou deixe sem atalho. Aperte Definir, segure os botões e solte. A mesma combinação não pode servir para duas ações. Você muda tudo isso depois em Ajustes.", + "ShortcutsUseSuggested": "Usar os atalhos sugeridos", + "ShortcutsOnboardingDone": "Concluir", "CloseSteamCard": "Fechar a Steam ao voltar ao PC", "CloseSteamDescription": "Ao sair do Big Picture ou voltar ao PC, a Steam é chamada para fechar do jeito normal (o \"Sair\" dela, nunca à força). O Big Picture sempre é fechado. A Steam fica aberta se houver um jogo rodando.", "AutoStartControllerCard": "Entrar ao conectar um controle", diff --git a/src/ConsoleMode/Services/ConsoleEngine.cs b/src/ConsoleMode/Services/ConsoleEngine.cs index db89699..42058c5 100644 --- a/src/ConsoleMode/Services/ConsoleEngine.cs +++ b/src/ConsoleMode/Services/ConsoleEngine.cs @@ -415,7 +415,10 @@ private AppConfig ResolveMonitorNames(AppConfig config) ConfirmedSetup = config.ConfirmedSetup, CheckUpdates = config.CheckUpdates, BetaUpdates = config.BetaUpdates, - HomeButtonLaunch = config.HomeButtonLaunch, + HomeShortcut = config.HomeShortcut, + MenuShortcut = config.MenuShortcut, + ExitShortcut = config.ExitShortcut, + ShortcutsOnboardingDone = config.ShortcutsOnboardingDone, HomeButtonShortPress = config.HomeButtonShortPress, CloseSteamOnRestore = config.CloseSteamOnRestore, AutoStartOnController = config.AutoStartOnController, diff --git a/src/ConsoleMode/Services/ControllerHoldWatcher.cs b/src/ConsoleMode/Services/ControllerHoldWatcher.cs index 76e958f..d3bbed0 100644 --- a/src/ConsoleMode/Services/ControllerHoldWatcher.cs +++ b/src/ConsoleMode/Services/ControllerHoldWatcher.cs @@ -5,8 +5,8 @@ namespace ConsoleMode.Services; /// /// Fires when a set of XInput buttons is held together for -/// (zero = fire on press). Used for the Xbox Guide button while the app idles in the tray -/// (enter console mode) and for Start + Back during a session (restore the desk), both of +/// (zero = fire on press). Used for the shortcuts the user picked (see ControllerShortcuts): +/// enter console mode from the tray, open the session menu and restore the desk, all of /// which work without focus. XInputGetStateEx (ordinal 100) is the only entry point that /// reports the Guide button; PlayStation pads are read through SonyHidReader. /// @@ -21,7 +21,6 @@ public sealed class ControllerHoldWatcher : IDisposable private static bool _unavailable; private readonly DispatcherQueueTimer _timer; - private readonly ushort _mask; private readonly string _name; private DateTime? _heldSince; private bool _fired; @@ -31,6 +30,21 @@ public sealed class ControllerHoldWatcher : IDisposable public TimeSpan HoldDuration { get; set; } = LongHold; + /// The combo to watch; changing it re-arms the watch (it must be released first). + public ushort Mask + { + get => _mask; + set + { + if (_mask == value) return; + _mask = value; + _heldSince = null; + _fired = true; + } + } + + private ushort _mask; + public ControllerHoldWatcher(DispatcherQueue dispatcher, ushort mask, string name) { _mask = mask; @@ -64,7 +78,8 @@ public void Stop() private void Poll() { - if (!IsHeld(_mask)) + // An empty combo is "held" by any pad state; a shortcut that isn't set never fires. + if (_mask == 0 || !IsHeld(_mask)) { _heldSince = null; _fired = false; @@ -78,10 +93,31 @@ private void Poll() Held?.Invoke(); } + /// Buttons held per XInput slot or Sony HID path, for shortcut capture. + public static IReadOnlyDictionary ReadHeldByDevice() + { + var held = new Dictionary(StringComparer.OrdinalIgnoreCase); + foreach (var (path, buttons) in SonyHidReader.ReadStates()) held[$"sony:{path}"] = buttons; + if (_unavailable) return held; + try + { + for (uint i = 0; i < 4; i++) + { + if (XInputGetStateEx(i, out var state) == 0) held[$"xinput:{i}"] = state.Gamepad.wButtons; + } + } + catch (Exception ex) when (ex is DllNotFoundException or EntryPointNotFoundException) + { + AppLog.Write($"Controle: atalhos indisponíveis: {ex.Message}"); + _unavailable = true; + } + return held; + } + private static bool IsHeld(ushort mask) { - // PlayStation pads without Steam Input: XInput never sees them (SonyHidReader). - if ((SonyHidReader.Held & mask) == mask) return true; + // Test each physical Sony pad separately; combining their masks creates impossible chords. + if (ControllerShortcuts.IsHeldOnAnyDevice(mask, SonyHidReader.ReadStates().Values)) return true; if (_unavailable) return false; try { diff --git a/src/ConsoleMode/Services/ControllerInput.cs b/src/ConsoleMode/Services/ControllerInput.cs index 411ef35..769eb03 100644 --- a/src/ConsoleMode/Services/ControllerInput.cs +++ b/src/ConsoleMode/Services/ControllerInput.cs @@ -198,8 +198,8 @@ private static bool IsDirection(ControllerAction action) => private static void ReadAll(bool[] held, StringBuilder? diag) { var xinputPads = ReadXInput(held, diag); - if (ControllerMapping.ShouldReadGamepads(xinputPads)) ReadGamepads(held, diag); - ReadRaw(held, xinputPads, diag); + var invalidGamepads = ReadRaw(held, xinputPads, diag); + if (ControllerMapping.ShouldReadGamepads(xinputPads)) ReadGamepads(held, diag, invalidGamepads); } /// SonyHidReader bits use the XInput layout, so they map like an Xbox pad. @@ -238,10 +238,15 @@ private static int ReadXInput(bool[] held, StringBuilder? diag) return count; } - private static void ReadGamepads(bool[] held, StringBuilder? diag) + private static void ReadGamepads(bool[] held, StringBuilder? diag, HashSet invalidGamepads) { foreach (var pad in Gamepad.Gamepads) { + if (invalidGamepads.Contains(pad)) + { + diag?.AppendLine("Gamepad: ignorado (leitura HID inválida)"); + continue; + } try { var r = pad.GetCurrentReading(); @@ -265,14 +270,25 @@ private static void ReadGamepads(bool[] held, StringBuilder? diag) } } - private static void ReadRaw(bool[] held, int xinputPads, StringBuilder? diag) + private static readonly Dictionary NoisyStreaks = []; + private static readonly HashSet DistrustedPads = []; + + private static HashSet ReadRaw(bool[] held, int xinputPads, StringBuilder? diag) { + var invalidGamepads = new HashSet(); foreach (var raw in RawGameController.RawGameControllers) { var name = SafeName(raw); try { - var isGamepad = Gamepad.FromGameController(raw) is not null; + var gamepad = Gamepad.FromGameController(raw); + var isGamepad = gamepad is not null; + if (DistrustedPads.Contains(name)) + { + if (gamepad is not null) invalidGamepads.Add(gamepad); + diag?.AppendLine($"HID {name}: ignorado (leitura inválida)"); + continue; + } // Xbox pads also show up here; XInput already covers them, unless XInput saw nothing. if (!ControllerMapping.ShouldReadHid(isGamepad, xinputPads)) { diag?.AppendLine($"HID {name}: coberto pelo XInput"); continue; } if (raw.ButtonCount < 2) continue; @@ -282,6 +298,24 @@ private static void ReadRaw(bool[] held, int xinputPads, StringBuilder? diag) var axes = new double[raw.AxisCount]; raw.GetCurrentReading(buttons, switches, axes); + // A pad with impossible readings is ignored (see ControllerMapping.MaxSimultaneousHidButtons). + var pressedCount = buttons.Count(on => on); + NoisyStreaks.TryGetValue(name, out var streak); + var distrust = ControllerMapping.UpdateHidTrust(pressedCount, ref streak); + NoisyStreaks[name] = streak; + if (distrust) + { + DistrustedPads.Add(name); + AppLog.Write($"Controles: {name} ignorado: leitura inválida ({pressedCount} botões ao mesmo tempo); reabra o app para tentar de novo"); + if (gamepad is not null) invalidGamepads.Add(gamepad); + continue; + } + if (pressedCount > ControllerMapping.MaxSimultaneousHidButtons) + { + if (gamepad is not null) invalidGamepads.Add(gamepad); + continue; + } + var (confirmIndex, backIndex, optionIndex, altIndex, menuIndex) = ControllerMapping.HidIndices(raw.HardwareVendorId); Set(held, ControllerAction.Confirm, buttons, confirmIndex); Set(held, ControllerAction.Back, buttons, backIndex); @@ -321,6 +355,7 @@ private static void ReadRaw(bool[] held, int xinputPads, StringBuilder? diag) diag?.AppendLine($"HID {name}: erro {ex.Message}"); } } + return invalidGamepads; } private static void Set(bool[] held, ControllerAction action, bool[] buttons, int index) diff --git a/src/ConsoleMode/Services/ControllerMapping.cs b/src/ConsoleMode/Services/ControllerMapping.cs index eba94d4..6fa983f 100644 --- a/src/ConsoleMode/Services/ControllerMapping.cs +++ b/src/ConsoleMode/Services/ControllerMapping.cs @@ -75,6 +75,27 @@ public static ushort SonyButtons(ReadOnlySpan report, bool dualSense) /// Whether to read Windows.Gaming.Input.Gamepad objects: only when XInput saw nothing. public static bool ShouldReadGamepads(int xinputPads) => xinputPads == 0; + /// + /// Most buttons a person holds on a HID pad at once. Some pads read wrong through Windows' + /// generic HID driver (a Switch Pro Controller reports 6 to 10 buttons down and a stuck D-pad + /// with nobody touching it); acting on that moves the focus and presses buttons by itself. + /// + public const int MaxSimultaneousHidButtons = 5; + + /// Consecutive polls over the limit before the pad is ignored for good. + public const int DistrustAfterPolls = 3; + + /// + /// Tracks one HID pad's readings: true once they were impossible for + /// polls in a row. A single reading over the limit is + /// skipped by the caller without judging the pad. + /// + public static bool UpdateHidTrust(int pressedButtons, ref int noisyStreak) + { + noisyStreak = pressedButtons > MaxSimultaneousHidButtons ? noisyStreak + 1 : 0; + return noisyStreak >= DistrustAfterPolls; + } + /// Stick axis from 0..1 (0.5 centred) to a direction; ±0.25 dead zone. public static (bool Left, bool Right, bool Up, bool Down) StickDirections(double x, double y) => (x < 0.25, x > 0.75, y < 0.25, y > 0.75); diff --git a/src/ConsoleMode/Services/ControllerShortcuts.cs b/src/ConsoleMode/Services/ControllerShortcuts.cs new file mode 100644 index 0000000..4ae9802 --- /dev/null +++ b/src/ConsoleMode/Services/ControllerShortcuts.cs @@ -0,0 +1,154 @@ +namespace ConsoleMode.Services; + +/// What a controller shortcut does. +public enum ShortcutSlot +{ + /// Idle in the tray: enter console mode. + Home, + /// In a session: open the menu over the game. + Menu, + /// In a session: go back to the PC (restore the desk). + Exit +} + +/// +/// Pure rules for the shortcuts the user picks: which buttons are allowed, what a combo is +/// called, which two shortcuts collide and how a "press the buttons you want" capture ends. +/// Buttons are XInput bits (Sony pads are translated to the same bits), 0 = not set. +/// +public static class ControllerShortcuts +{ + public const ushort Guide = 0x0400; + public const ushort Start = 0x0010; + public const ushort Back = 0x0020; + public const ushort A = 0x1000; + public const ushort B = 0x2000; + public const ushort X = 0x4000; + public const ushort Y = 0x8000; + + /// + /// Buttons a shortcut can use: the ones every pad reports, on XInput and on Sony HID alike. + /// The D-pad is left out because a Sony pad's left stick also sets those bits. + /// + public const ushort Allowed = Guide | Start | Back | A | B | X | Y; + + public static readonly ShortcutSlot[] Slots = [ShortcutSlot.Home, ShortcutSlot.Menu, ShortcutSlot.Exit]; + + /// The combo offered (never applied by itself) for each shortcut. + public static ushort Suggested(ShortcutSlot slot) => slot switch + { + ShortcutSlot.Home => Guide, + ShortcutSlot.Menu => Back | Y, + _ => Start | Back + }; + + /// Config value to a valid mask: unknown bits are dropped, junk becomes "not set". + public static ushort Sanitize(int raw) => raw is < 0 or > ushort.MaxValue ? (ushort)0 : (ushort)(raw & Allowed); + + public static int ButtonCount(ushort mask) => System.Numerics.BitOperations.PopCount(mask); + + /// Whether any one device holds the full combo. + public static bool IsHeldOnAnyDevice(ushort mask, IEnumerable deviceStates) => + mask != 0 && deviceStates.Any(buttons => (buttons & mask) == mask); + + /// + /// A combo of two or more buttons, or the Guide button alone. A single face button would + /// fire in the middle of any game. + /// + public static bool IsAcceptable(ushort mask) => + mask != 0 && (mask & ~Allowed) == 0 && ((mask & Guide) != 0 || ButtonCount(mask) >= 2); + + /// Two combos collide when one is the other or contains it: both would fire together. + public static bool Overlaps(ushort a, ushort b) + { + if (a == 0 || b == 0) return false; + var both = a & b; + return both == a || both == b; + } + + /// The other shortcut that would collide with, if any. + /// Saved masks indexed by . + public static ShortcutSlot? FindConflict(ShortcutSlot slot, ushort mask, IReadOnlyList current) + { + foreach (var other in Slots) + { + if (other != slot && Overlaps(mask, current[(int)other])) return other; + } + return null; + } + + /// "Select + Y" on an Xbox pad, "Create + △" on a PlayStation one; "" when not set. + public static string Format(ushort mask, bool playStation) + { + var parts = new List(); + void Add(ushort bit, string xbox, string sony) + { + if ((mask & bit) != 0) parts.Add(playStation ? sony : xbox); + } + Add(Guide, "Xbox", "PS"); + Add(Back, "Select", "Create"); + Add(Start, "Start", "Options"); + Add(A, "A", "✕"); + Add(B, "B", "○"); + Add(X, "X", "□"); + Add(Y, "Y", "△"); + return string.Join(" + ", parts); + } +} + +/// +/// Captures one device's buttons until that device is released, then reports the combo. +/// Each device arms independently after a neutral poll, so an unrelated held pad cannot delay +/// capture and inputs from different devices are never combined. +/// +public sealed class ShortcutCapture +{ + private readonly HashSet _releasedSinceStart = new(StringComparer.OrdinalIgnoreCase); + private string? _capturingDevice; + private ushort _seen; + + /// The current allowed buttons on the device being captured. + public ushort CurrentHeld { get; private set; } + + /// Feed one poll of device IDs and states; returns the combo on release/disconnect. + public ushort? Feed(IReadOnlyDictionary devices) + { + CurrentHeld = 0; + foreach (var id in _releasedSinceStart.Where(id => !devices.ContainsKey(id)).ToArray()) + _releasedSinceStart.Remove(id); + + if (_capturingDevice is not null) + { + if (!devices.TryGetValue(_capturingDevice, out var held) || + (held &= ControllerShortcuts.Allowed) == 0) + { + var device = _capturingDevice; + _capturingDevice = null; + if (devices.ContainsKey(device)) _releasedSinceStart.Add(device); + else _releasedSinceStart.Remove(device); + var result = _seen; + _seen = 0; + return result == 0 ? null : result; + } + CurrentHeld = held; + _seen |= held; + return null; + } + + foreach (var (id, buttons) in devices.OrderBy(pair => pair.Key, StringComparer.OrdinalIgnoreCase)) + { + var held = (ushort)(buttons & ControllerShortcuts.Allowed); + if (held == 0) + { + _releasedSinceStart.Add(id); + continue; + } + if (!_releasedSinceStart.Contains(id)) continue; + _capturingDevice = id; + CurrentHeld = held; + _seen = held; + break; + } + return null; + } +} diff --git a/src/ConsoleMode/Services/LocalizationService.cs b/src/ConsoleMode/Services/LocalizationService.cs index 1bc3df6..f425bf4 100644 --- a/src/ConsoleMode/Services/LocalizationService.cs +++ b/src/ConsoleMode/Services/LocalizationService.cs @@ -185,6 +185,8 @@ internal void NotifyAll() public string BackToPc => LocalizationService.Get(nameof(BackToPc)); public string ConsoleActiveHint => LocalizationService.Get(nameof(ConsoleActiveHint)); public string HomeButtonCard => LocalizationService.Get(nameof(HomeButtonCard)); + public string ExitShortcutCard => LocalizationService.Get(nameof(ExitShortcutCard)); + public string ExitShortcutDescription => LocalizationService.Get(nameof(ExitShortcutDescription)); public string HomeButtonDescription => LocalizationService.Get(nameof(HomeButtonDescription)); public string HomeButtonShortPressCard => LocalizationService.Get(nameof(HomeButtonShortPressCard)); public string CloseSteamCard => LocalizationService.Get(nameof(CloseSteamCard)); diff --git a/src/ConsoleMode/Services/SonyHidReader.cs b/src/ConsoleMode/Services/SonyHidReader.cs index a946d96..066377f 100644 --- a/src/ConsoleMode/Services/SonyHidReader.cs +++ b/src/ConsoleMode/Services/SonyHidReader.cs @@ -30,6 +30,12 @@ public static ushort Held } } + /// Copy each connected Sony HID pad's state without combining different devices. + public static IReadOnlyDictionary ReadStates() + { + lock (Gate) return new Dictionary(Buttons, StringComparer.OrdinalIgnoreCase); + } + public static void Acquire() { lock (Gate) diff --git a/src/ConsoleMode/Services/TrayService.cs b/src/ConsoleMode/Services/TrayService.cs index 48d899f..df1c0eb 100644 --- a/src/ConsoleMode/Services/TrayService.cs +++ b/src/ConsoleMode/Services/TrayService.cs @@ -82,6 +82,7 @@ public void ShowWindow() _window.Activate(); var hwnd = WinRT.Interop.WindowNative.GetWindowHandle(_window); Native.NativeWindows.ShowWindow(hwnd, 9); + _vm.RequestShortcutOnboarding(); }); } diff --git a/src/ConsoleMode/ViewModels/MainViewModel.ConsoleUi.cs b/src/ConsoleMode/ViewModels/MainViewModel.ConsoleUi.cs index 99e1bb1..134246f 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.ConsoleUi.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.ConsoleUi.cs @@ -49,6 +49,7 @@ partial void OnIsPlayStationHintsChanged(bool value) OnPropertyChanged(nameof(HintBack)); OnPropertyChanged(nameof(HintAlt)); OnPropertyChanged(nameof(HintMenu)); + NotifyShortcutTexts(); } partial void OnSelectedUiModeChanged(ComboOption? value) @@ -178,7 +179,9 @@ private void CycleSetting(string spec) case "hide": SelectedHideStrategy = Next(HideStrategies, SelectedHideStrategy, step); break; case "language": SelectedLanguage = Next(LanguageOptions, SelectedLanguage, step); break; case "ui": SelectedUiMode = Next(UiModeOptions, SelectedUiMode, step); break; - case "home": HomeButtonLaunch = !HomeButtonLaunch; break; + case "home": ToggleSuggestedShortcut(ShortcutSlot.Home); break; + case "menushortcut": ToggleSuggestedShortcut(ShortcutSlot.Menu); break; + case "exitshortcut": ToggleSuggestedShortcut(ShortcutSlot.Exit); break; case "shortpress": HomeButtonShortPress = !HomeButtonShortPress; break; case "autostart": AutoStartOnController = !AutoStartOnController; break; case "startup": StartWithWindows = !StartWithWindows; break; diff --git a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs index 8fb66cb..b8c6008 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs @@ -67,10 +67,10 @@ private async Task ShowSessionHintAsync() await Task.Delay(TimeSpan.FromSeconds(5)); _dispatcher.TryEnqueue(() => { - if (!IsConsoleActive || IsSessionMenuOpen) return; + if (!IsConsoleActive || IsSessionMenuOpen || !MenuShortcutSet) return; try { - var combo = IsPlayStationHints ? "Create + △" : "Select + Y"; + var combo = MenuShortcutText; _ = new SessionHintWindow(LocalizationService.Get("SessionHintTitle"), LocalizationService.Get("SessionHintBody", combo), Engine.State.FocusMonitorRect, TimeSpan.FromSeconds(7)); diff --git a/src/ConsoleMode/ViewModels/MainViewModel.Shortcuts.cs b/src/ConsoleMode/ViewModels/MainViewModel.Shortcuts.cs new file mode 100644 index 0000000..6b5eb0c --- /dev/null +++ b/src/ConsoleMode/ViewModels/MainViewModel.Shortcuts.cs @@ -0,0 +1,162 @@ +using ConsoleMode.Models; +using ConsoleMode.Services; + +namespace ConsoleMode.ViewModels; + +// The controller shortcuts the user picks (Home, session menu, back to the PC). Nothing is set +// until they choose in the first-run setup or in Settings; a combo can serve one purpose only. +public partial class MainViewModel +{ + /// Raised (as a PropertyChanged) whenever any shortcut changes. + public const string ShortcutsProperty = "Shortcuts"; + + private readonly ushort[] _shortcuts = new ushort[ControllerShortcuts.Slots.Length]; + private int _shortcutCaptures; + + /// The first-run setup asks the user to pick their shortcuts (desktop interface only). + public event Action? ShortcutOnboardingRequested; + + public ushort GetShortcut(ShortcutSlot slot) => _shortcuts[(int)slot]; + + public bool HomeShortcutSet => _shortcuts[(int)ShortcutSlot.Home] != 0; + public bool ExitShortcutSet => _shortcuts[(int)ShortcutSlot.Exit] != 0; + public bool MenuShortcutSet => _shortcuts[(int)ShortcutSlot.Menu] != 0; + + /// True while a "press the buttons you want" capture is running: no shortcut may fire. + public bool IsCapturingShortcut => _shortcutCaptures > 0; + + public bool NeedsShortcutOnboarding => !_loadedConfig.ShortcutsOnboardingDone; + + /// "Select + Y" / "Create + △" for the pad in use, or the "not set" text. + public string ShortcutText(ShortcutSlot slot) + { + var mask = GetShortcut(slot); + return mask == 0 + ? LocalizationService.Get("ShortcutNotSet") + : ControllerShortcuts.Format(mask, IsPlayStationHints); + } + + public string ShortcutName(ShortcutSlot slot) => LocalizationService.Get(slot switch + { + ShortcutSlot.Home => "ShortcutHome", + ShortcutSlot.Menu => "ShortcutMenu", + _ => "ShortcutExit" + }); + + public string HomeShortcutText => ShortcutText(ShortcutSlot.Home); + public string MenuShortcutText => ShortcutText(ShortcutSlot.Menu); + public string ExitShortcutText => ShortcutText(ShortcutSlot.Exit); + + /// The "or hold … to go back" line on the console screen; empty when the shortcut isn't set. + public string ConsoleActiveHintText => ExitShortcutSet + ? LocalizationService.Get("ConsoleActiveHint", ExitShortcutText) + : ""; + + /// + /// Sets a shortcut. Returns null on success, or the message to show when the combo is not + /// allowed or already belongs to another shortcut. + /// + public string? TrySetShortcut(ShortcutSlot slot, ushort mask) + { + if (!ControllerShortcuts.IsAcceptable(mask)) + return LocalizationService.Get("ShortcutNotAllowed"); + var conflict = ControllerShortcuts.FindConflict(slot, mask, _shortcuts); + if (conflict is { } other) + return LocalizationService.Get("ShortcutInUse", ControllerShortcuts.Format(mask, IsPlayStationHints), ShortcutName(other)); + + _shortcuts[(int)slot] = mask; + ShortcutsChanged(); + return null; + } + + public void ClearShortcut(ShortcutSlot slot) + { + if (_shortcuts[(int)slot] == 0) return; + _shortcuts[(int)slot] = 0; + ShortcutsChanged(); + } + + /// Fills the shortcuts that are still empty with the suggested combos (they never overlap each other). + public void UseSuggestedShortcuts() + { + foreach (var slot in ControllerShortcuts.Slots) + { + if (_shortcuts[(int)slot] != 0) continue; + var suggested = ControllerShortcuts.Suggested(slot); + if (ControllerShortcuts.FindConflict(slot, suggested, _shortcuts) is null) + _shortcuts[(int)slot] = suggested; + } + ShortcutsChanged(); + } + + public void BeginShortcutCapture() + { + _shortcutCaptures++; + OnPropertyChanged(ShortcutsProperty); + } + + public void EndShortcutCapture() + { + if (_shortcutCaptures > 0) _shortcutCaptures--; + OnPropertyChanged(ShortcutsProperty); + } + + /// Finishing or skipping the first-run setup: it is not offered again. + public void CompleteShortcutOnboarding() + { + if (_loadedConfig.ShortcutsOnboardingDone) return; + var config = BuildConfig(); + config.ShortcutsOnboardingDone = true; + TrySave(config); + } + + /// Asks the window to show the shortcuts setup, if it was never done and the desktop interface is up. + public void RequestShortcutOnboarding() + { + if (NeedsShortcutOnboarding && !IsConsoleUi && TourStep == 0) + ShortcutOnboardingRequested?.Invoke(); + } + + private void LoadShortcuts(AppConfig config) + { + _shortcuts[(int)ShortcutSlot.Home] = ControllerShortcuts.Sanitize(config.HomeShortcut); + _shortcuts[(int)ShortcutSlot.Menu] = ControllerShortcuts.Sanitize(config.MenuShortcut); + _shortcuts[(int)ShortcutSlot.Exit] = ControllerShortcuts.Sanitize(config.ExitShortcut); + // A hand-edited file could give one combo two jobs: the later one is dropped. + foreach (var slot in ControllerShortcuts.Slots) + { + var mask = _shortcuts[(int)slot]; + if (mask == 0) continue; + if (!ControllerShortcuts.IsAcceptable(mask) + || ControllerShortcuts.FindConflict(slot, mask, _shortcuts) is { } other && other < slot) + _shortcuts[(int)slot] = 0; + } + NotifyShortcutTexts(); + } + + /// Console interface: A on the row turns the shortcut off, or sets the suggested combo (capturing needs the desktop settings). + private void ToggleSuggestedShortcut(ShortcutSlot slot) + { + if (GetShortcut(slot) != 0) { ClearShortcut(slot); return; } + var error = TrySetShortcut(slot, ControllerShortcuts.Suggested(slot)); + if (error is not null) SetStatus(error, Microsoft.UI.Xaml.Controls.InfoBarSeverity.Warning); + } + + private void ShortcutsChanged() + { + NotifyShortcutTexts(); + SaveQuietly(); + } + + private void NotifyShortcutTexts() + { + OnPropertyChanged(nameof(HomeShortcutSet)); + OnPropertyChanged(nameof(MenuShortcutSet)); + OnPropertyChanged(nameof(ExitShortcutSet)); + OnPropertyChanged(nameof(HomeShortcutText)); + OnPropertyChanged(nameof(MenuShortcutText)); + OnPropertyChanged(nameof(ExitShortcutText)); + OnPropertyChanged(nameof(ConsoleActiveHintText)); + OnPropertyChanged(ShortcutsProperty); + } +} diff --git a/src/ConsoleMode/ViewModels/MainViewModel.Updates.cs b/src/ConsoleMode/ViewModels/MainViewModel.Updates.cs index 2cee39d..9b71c05 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.Updates.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.Updates.cs @@ -17,7 +17,6 @@ private enum UpdateStatusKind { InstallKind, Searching, Latest, Available, Faile [ObservableProperty] private bool _checkUpdates = true; [ObservableProperty] private bool _betaUpdates; - [ObservableProperty] private bool _homeButtonLaunch = true; [ObservableProperty] private bool _homeButtonShortPress; [ObservableProperty] private bool _autoStartOnController; [ObservableProperty] private bool _closeSteamOnRestore = true; @@ -89,8 +88,6 @@ partial void OnBetaUpdatesChanged(bool value) if (value) SetStatus(LocalizationService.Get("BetaUpdatesWarning"), InfoBarSeverity.Warning); } - partial void OnHomeButtonLaunchChanged(bool value) => SaveQuietly(); - partial void OnAutoStartOnControllerChanged(bool value) => SaveQuietly(); partial void OnCloseSteamOnRestoreChanged(bool value) => SaveQuietly(); diff --git a/src/ConsoleMode/ViewModels/MainViewModel.cs b/src/ConsoleMode/ViewModels/MainViewModel.cs index 6fdbdb0..80acd12 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.cs @@ -132,6 +132,7 @@ public async Task InitializeAsync(bool interactive = true) _ = RecheckControllerAsync(); // The tour points at desktop controls; the console interface explains itself. if (interactive && HasMonitors && !_loadedConfig.TourDone && !IsConsoleUi) StartTour(); + else if (interactive) RequestShortcutOnboarding(); _ = CheckForUpdatesOnStartupAsync(); } @@ -302,7 +303,7 @@ private void Apply(LoadResult data) VrrEnable = config.VrrEnable; CheckUpdates = config.CheckUpdates; BetaUpdates = config.BetaUpdates; - HomeButtonLaunch = config.HomeButtonLaunch; + LoadShortcuts(config); HomeButtonShortPress = config.HomeButtonShortPress; AutoStartOnController = config.AutoStartOnController; CloseSteamOnRestore = config.CloseSteamOnRestore; @@ -764,10 +765,13 @@ public void OnTourTipClosed(TeachingTip sender, TeachingTipClosedEventArgs args) private void EndTour() { TourStep = 0; - if (_loadedConfig.TourDone) return; - var config = BuildConfig(); - config.TourDone = true; - TrySave(config); + if (!_loadedConfig.TourDone) + { + var config = BuildConfig(); + config.TourDone = true; + TrySave(config); + } + RequestShortcutOnboarding(); } [RelayCommand] @@ -890,7 +894,10 @@ private AppConfig BuildConfig() ConfirmedSetup = _loadedConfig.ConfirmedSetup, CheckUpdates = CheckUpdates, BetaUpdates = BetaUpdates, - HomeButtonLaunch = HomeButtonLaunch, + HomeShortcut = _shortcuts[(int)ShortcutSlot.Home], + MenuShortcut = _shortcuts[(int)ShortcutSlot.Menu], + ExitShortcut = _shortcuts[(int)ShortcutSlot.Exit], + ShortcutsOnboardingDone = _loadedConfig.ShortcutsOnboardingDone, HomeButtonShortPress = HomeButtonShortPress, AutoStartOnController = AutoStartOnController, CloseSteamOnRestore = CloseSteamOnRestore, diff --git a/src/ConsoleMode/Views/ConsoleHomeView.xaml b/src/ConsoleMode/Views/ConsoleHomeView.xaml index 805d00b..1bb8279 100644 --- a/src/ConsoleMode/Views/ConsoleHomeView.xaml +++ b/src/ConsoleMode/Views/ConsoleHomeView.xaml @@ -351,7 +351,19 @@ + + - - diff --git a/src/ConsoleMode/Views/SettingsView.xaml b/src/ConsoleMode/Views/SettingsView.xaml index 9818fcc..3f9216c 100644 --- a/src/ConsoleMode/Views/SettingsView.xaml +++ b/src/ConsoleMode/Views/SettingsView.xaml @@ -68,19 +68,21 @@ SelectedItem="{x:Bind ViewModel.SelectedAudio, Mode=TwoWay}" DisplayMemberPath="Text"/> - + - - + + + + + + @@ -96,7 +98,7 @@ OnContent="{x:Bind ViewModel.Texts.ToggleOn, Mode=OneWay}" OffContent="{x:Bind ViewModel.Texts.ToggleOff, Mode=OneWay}"/> + IsEnabled="{x:Bind ViewModel.HomeShortcutSet, Mode=OneWay}"> diff --git a/src/ConsoleMode/Views/SettingsView.xaml.cs b/src/ConsoleMode/Views/SettingsView.xaml.cs index 6d773c0..0d5595c 100644 --- a/src/ConsoleMode/Views/SettingsView.xaml.cs +++ b/src/ConsoleMode/Views/SettingsView.xaml.cs @@ -1,3 +1,5 @@ +using ConsoleMode.Controls; +using ConsoleMode.Services; using ConsoleMode.ViewModels; using Microsoft.UI.Xaml.Controls; @@ -10,5 +12,8 @@ public sealed partial class SettingsView : UserControl public SettingsView() { InitializeComponent(); + HomeShortcutCard.Content = new ShortcutPicker(ViewModel, ShortcutSlot.Home); + MenuShortcutCard.Content = new ShortcutPicker(ViewModel, ShortcutSlot.Menu); + ExitShortcutCard.Content = new ShortcutPicker(ViewModel, ShortcutSlot.Exit); } } diff --git a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj index 011bcb9..0442c15 100644 --- a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj +++ b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj @@ -33,6 +33,7 @@ + diff --git a/tests/ConsoleMode.Tests/ControllerShortcutsTests.cs b/tests/ConsoleMode.Tests/ControllerShortcutsTests.cs new file mode 100644 index 0000000..a4e7617 --- /dev/null +++ b/tests/ConsoleMode.Tests/ControllerShortcutsTests.cs @@ -0,0 +1,193 @@ +using System.Text.Json; +using ConsoleMode.Models; +using ConsoleMode.Services; + +namespace ConsoleMode.Tests; + +public class ControllerShortcutsTests +{ + private static ushort[] Masks(ushort home = 0, ushort menu = 0, ushort exit = 0) => [home, menu, exit]; + private static IReadOnlyDictionary Devices(params (string Id, ushort Buttons)[] devices) => + devices.ToDictionary(device => device.Id, device => device.Buttons, StringComparer.OrdinalIgnoreCase); + + [Fact] + public void A_new_config_has_no_shortcut_set_and_no_setup_done() + { + var config = JsonSerializer.Deserialize("{}")!; + Assert.Equal(0, config.HomeShortcut); + Assert.Equal(0, config.MenuShortcut); + Assert.Equal(0, config.ExitShortcut); + Assert.False(config.ShortcutsOnboardingDone); + } + + [Fact] + public void An_old_config_with_the_home_button_on_still_starts_with_nothing_set() + { + var config = JsonSerializer.Deserialize("""{"homeButtonLaunch":true}""", + new JsonSerializerOptions { PropertyNameCaseInsensitive = true })!; + Assert.Equal(0, config.HomeShortcut); + Assert.False(config.ShortcutsOnboardingDone); + } + + [Fact] + public void Suggested_shortcuts_are_valid_and_do_not_collide() + { + foreach (var slot in ControllerShortcuts.Slots) + { + var suggested = ControllerShortcuts.Suggested(slot); + Assert.True(ControllerShortcuts.IsAcceptable(suggested), slot.ToString()); + Assert.Null(ControllerShortcuts.FindConflict(slot, suggested, Masks())); + } + var all = ControllerShortcuts.Slots.Select(ControllerShortcuts.Suggested).ToArray(); + foreach (var slot in ControllerShortcuts.Slots) + Assert.Null(ControllerShortcuts.FindConflict(slot, all[(int)slot], all)); + } + + [Theory] + [InlineData(ControllerShortcuts.Guide, true)] + [InlineData(ControllerShortcuts.Back | ControllerShortcuts.Y, true)] + [InlineData(ControllerShortcuts.Start | ControllerShortcuts.Back | ControllerShortcuts.A, true)] + [InlineData(ControllerShortcuts.Guide | ControllerShortcuts.A, true)] + [InlineData(ControllerShortcuts.A, false)] // a lone face button fires in every game + [InlineData(ControllerShortcuts.Start, false)] + [InlineData(0, false)] + [InlineData(0x0001 | 0x0002, false)] // D-pad: a Sony stick also sets these bits + [InlineData(ControllerShortcuts.A | 0x0100, false)] // shoulder bits are not reported by every pad + public void Only_combos_or_the_home_button_are_acceptable(int mask, bool expected) => + Assert.Equal(expected, ControllerShortcuts.IsAcceptable((ushort)mask)); + + [Fact] + public void The_same_combo_cannot_serve_two_actions() + { + var current = Masks(menu: ControllerShortcuts.Back | ControllerShortcuts.Y); + Assert.Equal(ShortcutSlot.Menu, ControllerShortcuts.FindConflict(ShortcutSlot.Exit, ControllerShortcuts.Back | ControllerShortcuts.Y, current)); + } + + [Fact] + public void A_combo_that_contains_or_is_inside_another_collides() + { + // Start + Back is held on the way to Start + Back + Y: both would fire together. + var current = Masks(exit: ControllerShortcuts.Start | ControllerShortcuts.Back); + Assert.Equal(ShortcutSlot.Exit, + ControllerShortcuts.FindConflict(ShortcutSlot.Menu, ControllerShortcuts.Start | ControllerShortcuts.Back | ControllerShortcuts.Y, current)); + Assert.Equal(ShortcutSlot.Exit, + ControllerShortcuts.FindConflict(ShortcutSlot.Home, ControllerShortcuts.Start | ControllerShortcuts.Back, current)); + } + + [Fact] + public void Sharing_one_button_is_fine_and_an_unset_shortcut_never_collides() + { + var current = Masks(menu: ControllerShortcuts.Back | ControllerShortcuts.Y); + Assert.Null(ControllerShortcuts.FindConflict(ShortcutSlot.Exit, ControllerShortcuts.Start | ControllerShortcuts.Back, current)); + Assert.Null(ControllerShortcuts.FindConflict(ShortcutSlot.Home, ControllerShortcuts.Guide, current)); + Assert.False(ControllerShortcuts.Overlaps(0, 0)); + } + + [Fact] + public void Re_picking_a_slot_does_not_collide_with_itself() + { + var current = Masks(menu: ControllerShortcuts.Back | ControllerShortcuts.Y); + Assert.Null(ControllerShortcuts.FindConflict(ShortcutSlot.Menu, ControllerShortcuts.Back | ControllerShortcuts.Y, current)); + } + + [Theory] + [InlineData(ControllerShortcuts.Back | ControllerShortcuts.Y, false, "Select + Y")] + [InlineData(ControllerShortcuts.Back | ControllerShortcuts.Y, true, "Create + △")] + [InlineData(ControllerShortcuts.Start | ControllerShortcuts.Back, false, "Select + Start")] + [InlineData(ControllerShortcuts.Start | ControllerShortcuts.Back, true, "Create + Options")] + [InlineData(ControllerShortcuts.Guide, false, "Xbox")] + [InlineData(ControllerShortcuts.Guide, true, "PS")] + [InlineData(0, false, "")] + public void Combos_are_named_for_the_pad_in_use(int mask, bool playStation, string expected) => + Assert.Equal(expected, ControllerShortcuts.Format((ushort)mask, playStation)); + + [Theory] + [InlineData(-1, 0)] + [InlineData(70000, 0)] + [InlineData(0x0001, 0)] // unknown bits are dropped + [InlineData(0x0020 | 0x8000, 0x8020)] + public void Saved_values_are_cleaned_up_on_load(int raw, int expected) => + Assert.Equal((ushort)expected, ControllerShortcuts.Sanitize(raw)); + + [Fact] + public void Capture_reports_the_whole_combo_once_the_pad_is_let_go() + { + var capture = new ShortcutCapture(); + Assert.Null(capture.Feed(Devices(("xinput:0", 0)))); // armed + Assert.Null(capture.Feed(Devices(("xinput:0", ControllerShortcuts.Back)))); + Assert.Null(capture.Feed(Devices(("xinput:0", ControllerShortcuts.Back | ControllerShortcuts.Y)))); + Assert.Null(capture.Feed(Devices(("xinput:0", ControllerShortcuts.Y)))); // released one first: still counts both + Assert.Equal((ushort)(ControllerShortcuts.Back | ControllerShortcuts.Y), capture.Feed(Devices(("xinput:0", 0)))); + Assert.Null(capture.Feed(Devices(("xinput:0", 0)))); // reported only once + } + + [Fact] + public void Capture_ignores_a_button_still_held_from_before_it_opened() + { + var capture = new ShortcutCapture(); + // The A that clicked "Set" is still down when capturing starts. + Assert.Null(capture.Feed(Devices(("xinput:0", ControllerShortcuts.A)))); + Assert.Null(capture.Feed(Devices(("xinput:0", ControllerShortcuts.A)))); + Assert.Null(capture.Feed(Devices(("xinput:0", 0)))); // released: now armed, and nothing was captured + Assert.Null(capture.Feed(Devices(("xinput:0", 0)))); + Assert.Null(capture.Feed(Devices(("xinput:0", ControllerShortcuts.Y | ControllerShortcuts.B)))); + Assert.Equal((ushort)(ControllerShortcuts.Y | ControllerShortcuts.B), capture.Feed(Devices(("xinput:0", 0)))); + } + + [Fact] + public void Capture_ignores_the_dpad_and_the_stick() + { + var capture = new ShortcutCapture(); + Assert.Null(capture.Feed(Devices(("xinput:0", 0)))); + Assert.Null(capture.Feed(Devices(("xinput:0", 0x0001 | 0x0008)))); // D-pad up + right + Assert.Null(capture.Feed(Devices(("xinput:0", 0)))); + } + + [Fact] + public void Capture_does_not_combine_simultaneous_inputs_from_two_controllers() + { + var capture = new ShortcutCapture(); + Assert.Null(capture.Feed(Devices(("xinput:0", 0), ("xinput:1", 0)))); + Assert.Null(capture.Feed(Devices(("xinput:0", ControllerShortcuts.Back), ("xinput:1", ControllerShortcuts.Y)))); + Assert.Equal(ControllerShortcuts.Back, capture.CurrentHeld); + Assert.Equal(ControllerShortcuts.Back, + capture.Feed(Devices(("xinput:0", 0), ("xinput:1", ControllerShortcuts.Y)))); + + Assert.Null(capture.Feed(Devices(("xinput:0", 0), ("xinput:1", ControllerShortcuts.Y)))); + Assert.Equal(ControllerShortcuts.Y, capture.CurrentHeld); + Assert.Equal(ControllerShortcuts.Y, capture.Feed(Devices(("xinput:0", 0), ("xinput:1", 0)))); + } + + [Fact] + public void A_held_unrelated_controller_does_not_delay_capture_on_an_armed_device() + { + var capture = new ShortcutCapture(); + Assert.Null(capture.Feed(Devices(("xinput:0", ControllerShortcuts.Back), ("xinput:1", 0)))); + Assert.Null(capture.Feed(Devices(("xinput:0", ControllerShortcuts.Back), ("xinput:1", ControllerShortcuts.Y)))); + Assert.Equal(ControllerShortcuts.Y, capture.CurrentHeld); + Assert.Equal(ControllerShortcuts.Y, capture.Feed(Devices(("xinput:0", ControllerShortcuts.Back), ("xinput:1", 0)))); + } + + [Fact] + public void Disconnecting_a_pad_finishes_its_capture_without_using_another_pads_buttons() + { + var capture = new ShortcutCapture(); + Assert.Null(capture.Feed(Devices(("sony:path", 0), ("xinput:1", 0)))); + Assert.Null(capture.Feed(Devices(("sony:path", ControllerShortcuts.Back), ("xinput:1", 0)))); + Assert.Equal(ControllerShortcuts.Back, capture.Feed(Devices(("xinput:1", ControllerShortcuts.Y)))); + + Assert.Null(capture.Feed(Devices(("xinput:1", ControllerShortcuts.Y)))); + Assert.Equal(ControllerShortcuts.Y, capture.Feed(Devices(("xinput:1", 0)))); + } + + [Fact] + public void A_combo_must_be_held_by_one_device() + { + Assert.False(ControllerShortcuts.IsHeldOnAnyDevice( + ControllerShortcuts.Back | ControllerShortcuts.Y, + [ControllerShortcuts.Back, ControllerShortcuts.Y])); + Assert.True(ControllerShortcuts.IsHeldOnAnyDevice( + ControllerShortcuts.Back | ControllerShortcuts.Y, + [ControllerShortcuts.Back, ControllerShortcuts.Back | ControllerShortcuts.Y])); + } +} diff --git a/tests/ConsoleMode.Tests/HidTrustTests.cs b/tests/ConsoleMode.Tests/HidTrustTests.cs new file mode 100644 index 0000000..f23300a --- /dev/null +++ b/tests/ConsoleMode.Tests/HidTrustTests.cs @@ -0,0 +1,45 @@ +using ConsoleMode.Services; + +namespace ConsoleMode.Tests; + +public class HidTrustTests +{ + [Fact] + public void A_pad_with_impossible_readings_is_distrusted_after_a_few_polls() + { + // A Switch Pro Controller read as generic HID: 6 to 10 buttons down, nobody touching it. + var streak = 0; + Assert.False(ControllerMapping.UpdateHidTrust(6, ref streak)); + Assert.False(ControllerMapping.UpdateHidTrust(8, ref streak)); + Assert.True(ControllerMapping.UpdateHidTrust(10, ref streak)); + } + + [Fact] + public void A_normal_pad_is_never_distrusted() + { + var streak = 0; + for (var i = 0; i < 100; i++) + Assert.False(ControllerMapping.UpdateHidTrust(i % 4, ref streak)); + Assert.Equal(0, streak); + } + + [Fact] + public void A_short_burst_over_the_limit_does_not_condemn_the_pad() + { + var streak = 0; + Assert.False(ControllerMapping.UpdateHidTrust(7, ref streak)); + Assert.False(ControllerMapping.UpdateHidTrust(7, ref streak)); + Assert.False(ControllerMapping.UpdateHidTrust(2, ref streak)); // back to normal: the count restarts + Assert.Equal(0, streak); + Assert.False(ControllerMapping.UpdateHidTrust(7, ref streak)); + Assert.False(ControllerMapping.UpdateHidTrust(7, ref streak)); + } + + [Fact] + public void The_limit_itself_is_allowed() + { + var streak = 0; + for (var i = 0; i < 10; i++) + Assert.False(ControllerMapping.UpdateHidTrust(ControllerMapping.MaxSimultaneousHidButtons, ref streak)); + } +}