diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index b7fc2f2..568890d 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -4,6 +4,9 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ## [Unreleased] ### What's new +- Redesigned Console interface, made to feel like a console: a top bar with three tabs (Home, Session and System) you switch with LB/RB (L1/R1 on PlayStation) with a slide animation, screen tiles that show what will happen to each monitor, quick settings with icons and a focus that grows as you move over it. Home brings the Play now banner and a summary of the current setup (game screen, audio, launcher and HDR/VRR), each opening its choices. (#117) +- Console background: by default, a collage of the covers of your installed Steam games under a dark gradient (the app only reads images Steam already downloaded; nothing is bundled). You can switch to just the gradient or to a picture of your own, in System → Background. (#117) +- Sounds in the Console interface when you move the focus, pick and go back (made by the app itself, at a low volume). Turn them off in Settings → Interface sounds. (#117) - 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. @@ -11,6 +14,7 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor - 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) +- Console interface: controller navigation no longer gets stuck. Up (and down) also works when the target card is not in the same column, the focus no longer lands on invisible containers and, when you switch tabs, it lands on the first item of the new one. (#117) - 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 de51adb..b3402f4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ## [Unreleased] ### Novidades +- Interface Console redesenhada, com cara de console: barra no topo com três abas (Início, Sessão e Sistema) que você troca com LB/RB (L1/R1 no PlayStation) com uma animação de deslize, blocos das telas que mostram o que vai acontecer com cada monitor, ajustes rápidos com ícones e foco que cresce ao passar. Início traz o banner com Jogar agora e um resumo do setup atual (tela de jogo, áudio, iniciador e HDR/VRR), que abre cada escolha. (#117) +- Plano de fundo do modo Console: por padrão, uma colagem com as capas dos seus jogos instalados na Steam sob um gradiente escuro (o app só lê as imagens que a Steam já baixou; nada é embutido). Dá para trocar por só o gradiente ou por uma imagem sua, em Sistema → Plano de fundo. (#117) +- Sons na interface Console ao mover o foco, escolher e voltar (sons criados pelo próprio app, em volume baixo). Dá para desligar em Ajustes → Sons da interface. (#117) - 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. @@ -11,6 +14,7 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH - 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) +- Interface Console: navegar pelo controle não trava mais. "Para cima" (e "para baixo") funciona também quando o bloco de destino não está na mesma coluna, o foco não para mais em contêineres invisíveis e, ao trocar de aba, ele cai no primeiro item dela. (#117) - 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/docs/TESTING.md b/docs/TESTING.md index 582a325..ea90627 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -6,7 +6,78 @@ Como testar em build de desenvolvimento: `dotnet build src/ConsoleMode -c Debug ## 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. +Pré-condições: config sem `shortcutsOnboardingDone` (instalação nova, ou apague a chave/`config.json`); controle Xbox e, se tiver, um PlayStation. Nenhum atalho vem ligado. + +### Primeira abertura da versão + +- [ ] Ao abrir o app (janela visível, interface desktop), aparece "Configure os atalhos do controle" com as três ações (Abrir o Console Mode, Menu da sessão, Voltar ao PC), todas "Não definido". Resultado: ______ +- [ ] Com o **tour** de primeiro uso pendente, o tour vem primeiro e a configuração dos atalhos aparece ao terminá-lo ou pulá-lo. Resultado: ______ +- [ ] Iniciado com `--tray` (janela oculta): nada aparece; ao abrir a janela pela bandeja, a configuração aparece. Resultado: ______ +- [ ] "Usar os atalhos sugeridos" preenche só os vazios (Xbox, Select + Y, Select + Start). Resultado: ______ +- [ ] Fechar a janela em "Concluir", com atalhos vazios, e reabrir o app: a configuração **não** volta a aparecer. Resultado: ______ +- [ ] Antes de concluir, nenhum atalho dispara (segurar Home, Select + Y, Start + Select não fazem nada). Resultado: ______ + +### Escolher um botão + +- [ ] "Definir": mostra "Segure os botões e depois solte…" e, ao segurar, os nomes dos botões aparecem ao vivo. Ao soltar, o atalho é gravado. Resultado: ______ +- [ ] O botão A que clicou em "Definir" (navegação por controle) **não** é gravado como atalho e a tela não navega enquanto captura. Resultado: ______ +- [ ] Um botão sozinho (A, B, X, Y, Start, Select) é recusado com a explicação; só o botão Home pode ficar sozinho. Resultado: ______ +- [ ] O direcional e o analógico não entram na captura. Resultado: ______ +- [ ] Repetir a mesma combinação em outra ação é recusado ("… já é o atalho de …"); uma combinação que **contém** a outra (Select + Start + Y vs Select + Start) também. Resultado: ______ +- [ ] Trocar o atalho de uma ação para a combinação que ela já tinha é aceito. Resultado: ______ +- [ ] "Remover" deixa a ação sem atalho: ela não dispara mais. Resultado: ______ +- [ ] Controle **PlayStation**: os nomes aparecem como Create/Options/△✕○□ e o atalho gravado funciona na sessão (o app lê o controle por HID). Resultado: ______ +- [ ] Enquanto captura, um atalho já existente **não** dispara (ex.: capturar o Home com o Home já definido não abre o modo console). Resultado: ______ +- [ ] Os atalhos ficam salvos ao fechar e abrir o app (`homeShortcut`, `menuShortcut`, `exitShortcut` no `config.json`). Resultado: ______ +- [ ] Ajustes → cards de Home, menu da sessão e voltar ao PC mostram o mesmo estado; o "Toque curto" só fica ativo com o atalho Home definido. Resultado: ______ +- [ ] Interface Console → Ajustes: A numa linha de atalho liga com o padrão sugerido ou desliga; a dica "Ou segure … no controle" na sessão mostra o atalho de voltar (e some se não houver). Resultado: ______ +- [ ] O toast "Menu do Console Mode" no início da sessão mostra o atalho escolhido e **não** aparece se o menu não tiver atalho. Resultado: ______ +- [ ] Um `config.json` de versão anterior (com `homeButtonLaunch: true`) abre sem atalhos ligados e mostra a configuração. Resultado: ______ + +## 0b. Interface Console: abas, fundo e sons + +Pré-condições: interface Console ativa (Ajustes → Interface → Console, ou controle conectado no modo Automático); controle Xbox e, se tiver, PlayStation. + +### Abas (LB / RB) + +- [ ] Três abas no topo: Início, Sessão e Sistema. A ativa fica destacada e sublinhada; as outras, mais apagadas. Resultado: ______ +- [ ] **RB** vai para a próxima aba e **LB** para a anterior; nas pontas não dá a volta. Com **L1/R1** no PlayStation (aparece L1/R1 nas dicas). Resultado: ______ +- [ ] PageUp/PageDown no teclado e o clique do mouse nas abas também trocam. Resultado: ______ +- [ ] A página nova entra deslizando e aparecendo: RB entra pela direita e LB pela esquerda, em cerca de 0,25 s; trocar rápido várias vezes não trava nem deixa a página pela metade. Com as animações do Windows desligadas (Acessibilidade → Efeitos visuais), a troca é instantânea. Resultado: ______ +- [ ] Ao trocar de aba o foco vai para o primeiro item dela (Jogar agora, a primeira tela, a primeira linha). Resultado: ______ +- [ ] LB/RB não trocam a aba com um painel aberto por cima (papel da tela, seletor, teste do controle) nem durante a sessão. Resultado: ______ +- [ ] **B** em Sessão ou Sistema volta para Início; **Y** vai para Sistema e volta. Resultado: ______ +- [ ] **Início** mostra o banner com "Jogar agora" e, abaixo, "Setup atual" com quatro atalhos (tela de jogo, áudio, iniciador e HDR/VRR): os três primeiros abrem a escolha e o quarto leva à aba Sessão. O texto do banner cabe em até 2 linhas, também na janela pequena. Resultado: ______ +- [ ] O fundo de capas cobre a janela toda, sem faixa sem gradiente no topo (sob a barra de título). Resultado: ______ +- [ ] **Sessão** mostra as telas (papéis) e os ajustes rápidos; **Sistema** tem todas as linhas de ajustes (atalhos, sons, interface, fundo, atualizações). Resultado: ______ +- [ ] Janela pequena (960×760): nada some; Sessão rola e Sistema também. Resultado: ______ + +### Navegação pelo controle (direcional e analógico) + +- [ ] Em Sessão, com o foco num ajuste rápido à direita (ex.: "Limite de FPS"), **cima** sobe para uma tela (a mais próxima), não fica parado. Resultado: ______ +- [ ] Em Sessão, **baixo** das telas vai para o ajuste rápido mais próximo; esquerda/direita andam só dentro da própria fileira e param nas pontas. Resultado: ______ +- [ ] Ao trocar de aba com LB/RB o foco cai no primeiro item (Início: Jogar agora; Sessão: primeira tela; Sistema: primeira linha), com o anel branco visível. Resultado: ______ +- [ ] Em Início, baixo de Jogar agora vai para "Setup atual"; cima volta; cima de Jogar agora vai ao botão Modo desktop do topo. Resultado: ______ +- [ ] Em Sistema, cima/baixo percorrem todas as linhas, a lista rola e o anel nunca some; nenhuma tecla "não faz nada" no meio da lista. Resultado: ______ +- [ ] Segurar cima/baixo repete sem pular nem travar; uma diagonal do analógico anda na vertical. Resultado: ______ +- [ ] O log (`consolemode.log`) não mostra `Controles: navegação: Parâmetro incorreto`. Resultado: ______ + +### Blocos e foco + +- [ ] Cada tela é desenhada como um monitor: verde com ▶ para "Jogar aqui", escuro com ⏻ para "Desligar", azul para "Manter ligada"; a pílula do papel e a resolução aparecem. Trocar o papel atualiza o desenho. Resultado: ______ +- [ ] O bloco em foco cresce um pouco e ganha contorno branco; volta ao normal ao sair. As linhas de Ajustes não crescem. Resultado: ______ + +### Plano de fundo + +- [ ] Padrão ("Capas da Steam"): colagem de capas dos jogos instalados sob o gradiente, o texto continua legível; sem Steam ou sem capas, só o gradiente. Resultado: ______ +- [ ] Sistema → Plano de fundo alterna Capas da Steam / Só gradiente / Minha imagem; "Minha imagem" abre o seletor de arquivo e usa a imagem escolhida; cancelar sem imagem volta ao modo anterior. Resultado: ______ +- [ ] A escolha fica salva ao reabrir (`consoleBackground` e `consoleBackgroundImage` no `config.json`). Resultado: ______ + +### Sons + +- [ ] Há um som ao mover o foco, outro ao confirmar (A ou clique) e outro ao voltar (B); trocar de aba toca o de mover. Resultado: ______ +- [ ] Sistema/Ajustes → "Sons da interface" desligado: silêncio total. Resultado: ______ +- [ ] Segurar o direcional não acumula sons (um novo interrompe o anterior). Resultado: ______ - [ ] **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: ______ @@ -18,11 +89,11 @@ Pré-condições: atalho de "Abrir o Console Mode" definido (o padrão sugerido - [ ] **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: ______ -- [ ] Desligar "Abrir com o botão Home do controle" em Ajustes: nenhum dos gestos acima funciona. Resultado: ______ -- [ ] O log (`consolemode.log`) mostra `Controle: botão Home` / `Controle: Start + Back` a cada disparo. Resultado: ______ -- [ ] Controle **PlayStation**: confirmar que o Home **não** funciona na bandeja (limitação documentada) e que o app não registra erro. Resultado: ______ +- [ ] Com a sessão **ativa** (Big Picture aberto), segurar o atalho de abrir **não** reinicia nada. Resultado: ______ +- [ ] **Segurar o atalho de voltar ao PC por 1 s** durante a sessão restaura a mesa (igual a "Restaurar agora"). Resultado: ______ +- [ ] Remover o atalho de abrir em Ajustes: ele para de funcionar, e o de voltar ao PC continua. Resultado: ______ +- [ ] O log (`consolemode.log`) mostra `Controle: atalho Home` / `Controle: atalho Voltar ao PC` / `Controle: atalho Menu da sessão` a cada disparo. Resultado: ______ +- [ ] Controle **PlayStation**: o botão PS como atalho de abrir na bandeja só funciona com a leitura HID ativa; confirmar o comportamento e que o app não registra erro. Resultado: ______ ### Toque curto (desliga o atalho da Game Bar) @@ -45,7 +116,7 @@ Pré-condições: Ajustes → "Entrar ao conectar um controle" **ligado**; app n ## 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. +Pré-condições: sessão ativa com Big Picture (ou jogo borderless) na tela; controle Xbox ou PlayStation; atalhos do menu e de voltar ao PC definidos. Nos passos abaixo, "Select + Y" e "Start + Select" são os atalhos sugeridos: use os que você escolheu. - [ ] Segurar **Select + Y** por ~0,3 s abre o menu centralizado na tela de jogo, por cima do Big Picture, com foco em "Voltar ao jogo". Resultado: ______ - [ ] Select + Y de novo (ou B, ou Esc) fecha o menu e o jogo continua onde estava. Resultado: ______ diff --git a/src/ConsoleMode/App.xaml b/src/ConsoleMode/App.xaml index 9d61069..6c7af0a 100644 --- a/src/ConsoleMode/App.xaml +++ b/src/ConsoleMode/App.xaml @@ -82,17 +82,59 @@ - + - - + + + + + + + + + - - + + + - + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + - @@ -96,113 +156,473 @@ - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - - - - - - - - - - - + + + + - - + + + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + - - - - + + + - - - - - - @@ -222,6 +642,7 @@ + @@ -291,196 +712,6 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/src/ConsoleMode/Views/ConsoleHomeView.xaml.cs b/src/ConsoleMode/Views/ConsoleHomeView.xaml.cs index b3d9c4f..4be4433 100644 --- a/src/ConsoleMode/Views/ConsoleHomeView.xaml.cs +++ b/src/ConsoleMode/Views/ConsoleHomeView.xaml.cs @@ -1,4 +1,6 @@ using System.ComponentModel; +using System.Numerics; +using Microsoft.UI.Xaml.Hosting; using ConsoleMode.Services; using ConsoleMode.ViewModels; using Microsoft.UI.Xaml; @@ -14,15 +16,45 @@ public sealed partial class ConsoleHomeView : UserControl private GamepadNavigator? _navigator; private bool _windowActive = true; + private Microsoft.UI.Dispatching.DispatcherQueueTimer? _clock; + private static Style? _cardStyle; + private int _shownTab; + private static readonly Windows.UI.ViewManagement.UISettings Ui = new(); public ConsoleHomeView() { ViewModel = App.ViewModel!; InitializeComponent(); Loaded += OnLoaded; - Unloaded += (_, _) => { _navigator?.Dispose(); _navigator = null; }; - Root.KeyDown += OnKeyDown; + Unloaded += (_, _) => { _navigator?.Dispose(); _navigator = null; _clock?.Stop(); }; + // Preview (tunnelling): a ScrollViewer would otherwise swallow the arrows to scroll the page. + Root.PreviewKeyDown += OnKeyDown; + // The focused card grows a little, like the Xbox home. + Root.GotFocus += (_, e) => ScaleTile(e.OriginalSource, 1.07f); + Root.LostFocus += (_, e) => ScaleTile(e.OriginalSource, 1f); + // Mouse clicks on buttons sound like a confirm (the pad has its own sounds in the navigator). + Root.AddHandler(UIElement.PointerPressedEvent, new PointerEventHandler(OnPointerPressed), true); ViewModel.PropertyChanged += OnViewModelChanged; + ViewModel.PickBackgroundImageRequested += () => _ = PickBackgroundImageAsync(); + } + + /// "My own image": the system file picker (needs the window handle in WinUI 3). + private async Task PickBackgroundImageAsync() + { + try + { + if (App.MainWindowInstance is not { } window) return; + var picker = new Windows.Storage.Pickers.FileOpenPicker { SuggestedStartLocation = Windows.Storage.Pickers.PickerLocationId.PicturesLibrary }; + foreach (var extension in new[] { ".jpg", ".jpeg", ".png", ".bmp", ".webp" }) picker.FileTypeFilter.Add(extension); + WinRT.Interop.InitializeWithWindow.Initialize(picker, WinRT.Interop.WindowNative.GetWindowHandle(window)); + var file = await picker.PickSingleFileAsync(); + if (file is not null) ViewModel.SetBackgroundImage(file.Path); + else if (!SteamArt.IsSupportedImage(ViewModel.ConsoleBackgroundImage)) ViewModel.CycleBackgroundMode(); + } + catch (Exception ex) + { + AppLog.Write($"Fundo: seletor de imagem: {ex.Message}"); + } } private void OnLoaded(object sender, RoutedEventArgs e) @@ -35,7 +67,9 @@ private void OnLoaded(object sender, RoutedEventArgs e) : ViewModel.IsConsoleSettingsOpen ? SettingsPanel : ViewModel.IsRolePanelOpen ? RolePanel : Root; + _navigator.Sounds = true; _navigator.BackRequested += GoBack; + _navigator.TabRequested += StepTab; _navigator.MenuRequested += () => { if (ViewModel.CanStart && !ViewModel.IsRolePanelOpen && !ViewModel.IsConsoleSettingsOpen && !ViewModel.IsPickerOpen && !ViewModel.IsUpdateOpen) ViewModel.StartCommand.Execute(null); }; _navigator.AltRequested += () => { @@ -50,6 +84,7 @@ private void OnLoaded(object sender, RoutedEventArgs e) RefreshNavigator(); }; RefreshNavigator(); + StartClock(); FocusDefault(); } @@ -92,6 +127,17 @@ private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e) else FocusDefault(); }); break; + case nameof(MainViewModel.ConsoleTabIndex): + DispatcherQueue.TryEnqueue(() => + { + // The new page was just made visible: lay it out first, or nothing in it can take the focus yet + // (the focus would stay on whatever is left visible, like a button in the top bar). + TabPanel(ViewModel.ConsoleTabIndex).UpdateLayout(); + AnimateTab(_shownTab, ViewModel.ConsoleTabIndex); + _shownTab = ViewModel.ConsoleTabIndex; + FocusDefault(); + }); + break; case nameof(MainViewModel.IsConsoleSettingsOpen): DispatcherQueue.TryEnqueue(() => { @@ -120,6 +166,8 @@ private void FocusDefault() if (!ViewModel.IsConsoleUi) return; if (ViewModel.IsConsoleActive) { RestoreButton.Focus(FocusState.Keyboard); return; } if (ViewModel.IsUpdateOpen) { UpdateNowButton.Focus(FocusState.Keyboard); return; } + if (ViewModel.IsSystemTab) { FirstSettingsRow.Focus(FocusState.Keyboard); return; } + if (ViewModel.IsSessionTab) { (FocusManager.FindFirstFocusableElement(ScreenCards) as Control)?.Focus(FocusState.Keyboard); return; } if (ViewModel.CanStart) { PlayButton.Focus(FocusState.Keyboard); return; } (FocusManager.FindFirstFocusableElement(ScreenCards) as Control)?.Focus(FocusState.Keyboard); } @@ -144,21 +192,130 @@ private bool TryFocusPickerRow(int index) return (FocusManager.FindFirstFocusableElement(container) as Control)?.Focus(FocusState.Keyboard) == true; } + private FrameworkElement TabPanel(int tab) => tab switch + { + MainViewModel.HomeTabIndex => HomePanel, + MainViewModel.SessionTabIndex => SessionPanel, + _ => SettingsPanel + }; + + /// + /// The page that just appeared slides in from the side the tab came from (RB: right, LB: left) + /// while it fades in. Skipped when Windows has animations turned off. + /// + private void AnimateTab(int from, int to) + { + if (from == to || !Ui.AnimationsEnabled) return; + var panel = TabPanel(to); + try + { + ElementCompositionPreview.SetIsTranslationEnabled(panel, true); + var visual = ElementCompositionPreview.GetElementVisual(panel); + var compositor = visual.Compositor; + var easing = compositor.CreateCubicBezierEasingFunction(new Vector2(0.1f, 0.9f), new Vector2(0.2f, 1f)); + + var slide = compositor.CreateScalarKeyFrameAnimation(); + slide.InsertKeyFrame(0f, to > from ? 56f : -56f); + slide.InsertKeyFrame(1f, 0f, easing); + slide.Duration = TimeSpan.FromMilliseconds(260); + visual.StartAnimation("Translation.X", slide); + + var fade = compositor.CreateScalarKeyFrameAnimation(); + fade.InsertKeyFrame(0f, 0f); + fade.InsertKeyFrame(1f, 1f, easing); + fade.Duration = TimeSpan.FromMilliseconds(220); + visual.StartAnimation("Opacity", fade); + } + catch (Exception ex) + { + AppLog.Write($"Abas: animação: {ex.Message}"); + } + } + + /// LB / RB: the previous / next tab, unless a panel is open over the page. + private void StepTab(int step) + { + if (ViewModel.IsConsoleActive || ViewModel.IsRolePanelOpen || ViewModel.IsPickerOpen || ViewModel.IsControllerTestOpen) return; + if (ViewModel.StepConsoleTab(step)) UiSounds.Play(UiSound.Move); + } + private void GoBack() { if (ViewModel.IsControllerTestOpen) ViewModel.CloseControllerTestCommand.Execute(null); else if (ViewModel.IsPickerOpen) ViewModel.ClosePickerCommand.Execute(null); else if (ViewModel.IsRolePanelOpen) ViewModel.CloseRolePanelCommand.Execute(null); - else if (ViewModel.IsConsoleSettingsOpen) ViewModel.CloseConsoleSettingsCommand.Execute(null); + else if (!ViewModel.IsHomeTab) ViewModel.SelectConsoleTabCommand.Execute("0"); else if (ViewModel.IsUpdateOpen && !ViewModel.IsUpdating) ViewModel.DismissUpdateCommand.Execute(null); } + private void StartClock() + { + _clock ??= DispatcherQueue.CreateTimer(); + _clock.Interval = TimeSpan.FromSeconds(15); + _clock.Tick -= OnClockTick; + _clock.Tick += OnClockTick; + OnClockTick(null!, null!); + _clock.Start(); + } + + private void OnClockTick(Microsoft.UI.Dispatching.DispatcherQueueTimer sender, object args) => + ClockText.Text = DateTime.Now.ToString("HH:mm"); + + /// Cards (not settings rows) grow when focused and shrink back when it leaves. + private static void ScaleTile(object source, float scale) + { + if (source is not Button button) return; + _cardStyle ??= Application.Current.Resources["ConsoleCard"] as Style; + if (!ReferenceEquals(button.Style, _cardStyle)) return; + button.CenterPoint = new Vector3((float)button.ActualWidth / 2, (float)button.ActualHeight / 2, 0); + button.ScaleTransition ??= new Vector3Transition { Duration = TimeSpan.FromMilliseconds(140) }; + button.Scale = new Vector3(scale, scale, 1); + } + + private void OnPointerPressed(object sender, PointerRoutedEventArgs e) + { + for (var node = e.OriginalSource as DependencyObject; node is not null; node = Microsoft.UI.Xaml.Media.VisualTreeHelper.GetParent(node)) + { + if (node is Microsoft.UI.Xaml.Controls.Primitives.ButtonBase { IsEnabled: true }) + { + UiSounds.Play(UiSound.Confirm); + return; + } + } + } + + private static Microsoft.UI.Xaml.Input.FocusNavigationDirection? Arrow(VirtualKey key) => key switch + { + VirtualKey.Up => Microsoft.UI.Xaml.Input.FocusNavigationDirection.Up, + VirtualKey.Down => Microsoft.UI.Xaml.Input.FocusNavigationDirection.Down, + VirtualKey.Left => Microsoft.UI.Xaml.Input.FocusNavigationDirection.Left, + VirtualKey.Right => Microsoft.UI.Xaml.Input.FocusNavigationDirection.Right, + _ => null + }; + private void OnKeyDown(object sender, KeyRoutedEventArgs e) { - if (e.Key is VirtualKey.Escape or VirtualKey.GamepadB) + // The pad is read by the navigator (polling), never through these key events, or every press + // would count twice. The keyboard goes through the same navigator, so both move the same way. + if (e.Key is VirtualKey.Escape) { GoBack(); e.Handled = true; } + else if (e.Key is VirtualKey.PageUp) + { + StepTab(-1); + e.Handled = true; + } + else if (e.Key is VirtualKey.PageDown) + { + StepTab(1); + e.Handled = true; + } + else if (_navigator is not null && Arrow(e.Key) is { } direction) + { + _navigator.Navigate(direction); + e.Handled = true; + } } } diff --git a/src/ConsoleMode/Views/SettingsView.xaml b/src/ConsoleMode/Views/SettingsView.xaml index 3f9216c..225ab1a 100644 --- a/src/ConsoleMode/Views/SettingsView.xaml +++ b/src/ConsoleMode/Views/SettingsView.xaml @@ -159,6 +159,13 @@ SelectedItem="{x:Bind ViewModel.SelectedUiMode, Mode=TwoWay}" DisplayMemberPath="Text"/> + + + + + + diff --git a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj index 0442c15..7c3e5d9 100644 --- a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj +++ b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj @@ -34,6 +34,8 @@ + + diff --git a/tests/ConsoleMode.Tests/ControllerShoulderTests.cs b/tests/ConsoleMode.Tests/ControllerShoulderTests.cs new file mode 100644 index 0000000..b0c4fc1 --- /dev/null +++ b/tests/ConsoleMode.Tests/ControllerShoulderTests.cs @@ -0,0 +1,44 @@ +using ConsoleMode.Services; + +namespace ConsoleMode.Tests; + +public class ControllerShoulderTests +{ + // DS4 over USB: report 0x01, hat + face buttons at byte 5, L1/R1/Create/Options at byte 6. + private static byte[] Ds4Usb(byte hatAndFace, byte shoulders) + { + var report = new byte[64]; + report[0] = 0x01; + report[1] = report[2] = 128; // stick centred + report[5] = hatAndFace; + report[6] = shoulders; + return report; + } + + [Fact] + public void L1_and_R1_become_LB_and_RB() + { + Assert.Equal(0x0100, ControllerMapping.SonyButtons(Ds4Usb(0x08, 0x01), dualSense: false) & 0x0100); + Assert.Equal(0x0200, ControllerMapping.SonyButtons(Ds4Usb(0x08, 0x02), dualSense: false) & 0x0200); + } + + [Fact] + public void Shoulders_do_not_leak_into_the_other_buttons() + { + var bits = ControllerMapping.SonyButtons(Ds4Usb(0x08, 0x03), dualSense: false); + Assert.Equal(0x0300, bits); // only LB + RB: no Back (0x20), Start (0x10), A/B/X/Y + } + + [Fact] + public void Nothing_pressed_reads_zero() + { + Assert.Equal(0, ControllerMapping.SonyButtons(Ds4Usb(0x08, 0x00), dualSense: false)); + } + + [Fact] + public void Hid_pads_use_buttons_4_and_5_for_the_shoulders() + { + Assert.Equal(4, ControllerMapping.HidShoulderLeft); + Assert.Equal(5, ControllerMapping.HidShoulderRight); + } +} diff --git a/tests/ConsoleMode.Tests/SteamArtTests.cs b/tests/ConsoleMode.Tests/SteamArtTests.cs new file mode 100644 index 0000000..639c978 --- /dev/null +++ b/tests/ConsoleMode.Tests/SteamArtTests.cs @@ -0,0 +1,94 @@ +using ConsoleMode.Services; + +namespace ConsoleMode.Tests; + +public class SteamArtTests +{ + private const string Vdf = """ + "libraryfolders" + { + "0" + { + "path" "C:\Program Files (x86)\Steam" + "label" "" + "totalsize" "0" + "apps" + { + "228980" "192842418" + "1245620" "58231457312" + } + } + "1" + { + "path" "D:\SteamLibrary" + "apps" + { + "1245620" "58231457312" + "570" "40000000000" + } + } + } + """; + + [Fact] + public void Installed_games_come_from_every_library_folder_without_repeats() + { + Assert.Equal([228980, 1245620, 570], SteamArt.ParseInstalledAppIds(Vdf)); + } + + [Theory] + [InlineData("")] + [InlineData("not a vdf at all")] + [InlineData("\"libraryfolders\" { \"0\" { \"path\" \"C:\\Steam\" } }")] + public void A_file_without_games_gives_an_empty_list(string vdf) => + Assert.Empty(SteamArt.ParseInstalledAppIds(vdf)); + + [Fact] + public void The_cover_lives_in_steams_library_cache() + { + var path = SteamArt.CoverPath(Path.Combine("C:", "Steam"), 1245620); + Assert.EndsWith(Path.Combine("appcache", "librarycache", "1245620", "library_600x900.jpg"), path); + } + + [Fact] + public void Pick_keeps_only_games_with_a_cover_and_respects_the_count() + { + var picked = SteamArt.Pick([1, 2, 3, 4, 5, 6], id => id % 2 == 0, 2, new Random(7)); + Assert.Equal(2, picked.Count); + Assert.All(picked, id => Assert.True(id % 2 == 0)); + Assert.Equal(picked.Count, picked.Distinct().Count()); + } + + [Fact] + public void Pick_returns_what_exists_when_there_are_fewer_than_asked() + { + var picked = SteamArt.Pick([1, 2, 3], _ => true, 40, new Random(1)); + Assert.Equal([1, 2, 3], picked.Order().ToList()); + } + + [Fact] + public void Pick_shuffles_so_the_collage_changes_between_launches() + { + var ids = Enumerable.Range(1, 60).ToList(); + var a = SteamArt.Pick(ids, _ => true, 20, new Random(1)); + var b = SteamArt.Pick(ids, _ => true, 20, new Random(2)); + Assert.NotEqual(a, b); + } + + [Theory] + [InlineData(0)] + [InlineData(-3)] + public void Pick_with_no_room_returns_nothing(int count) => + Assert.Empty(SteamArt.Pick([1, 2, 3], _ => true, count, new Random(1))); + + [Theory] + [InlineData("C:/pics/wall.JPG", true)] + [InlineData("wall.png", true)] + [InlineData("wall.webp", true)] + [InlineData("wall.gif", false)] + [InlineData("wall.exe", false)] + [InlineData("", false)] + [InlineData(null, false)] + public void Only_common_image_types_are_accepted_for_your_own_image(string? path, bool expected) => + Assert.Equal(expected, SteamArt.IsSupportedImage(path)); +} diff --git a/tests/ConsoleMode.Tests/UiSoundSynthTests.cs b/tests/ConsoleMode.Tests/UiSoundSynthTests.cs new file mode 100644 index 0000000..2753f42 --- /dev/null +++ b/tests/ConsoleMode.Tests/UiSoundSynthTests.cs @@ -0,0 +1,56 @@ +using System.Text; +using ConsoleMode.Services; + +namespace ConsoleMode.Tests; + +public class UiSoundSynthTests +{ + public static IEnumerable AllSounds() => Enum.GetValues().Select(s => new object[] { s }); + + [Theory] + [MemberData(nameof(AllSounds))] + public void Every_sound_is_a_valid_16_bit_mono_wav(UiSound sound) + { + var wav = UiSoundSynth.Wav(sound); + Assert.Equal("RIFF", Encoding.ASCII.GetString(wav, 0, 4)); + Assert.Equal("WAVE", Encoding.ASCII.GetString(wav, 8, 4)); + Assert.Equal("data", Encoding.ASCII.GetString(wav, 36, 4)); + Assert.Equal((short)1, BitConverter.ToInt16(wav, 20)); // PCM + Assert.Equal((short)1, BitConverter.ToInt16(wav, 22)); // mono + Assert.Equal(UiSoundSynth.SampleRate, BitConverter.ToInt32(wav, 24)); + Assert.Equal((short)16, BitConverter.ToInt16(wav, 34)); + Assert.Equal(wav.Length - 44, BitConverter.ToInt32(wav, 40)); + Assert.Equal(wav.Length - 8, BitConverter.ToInt32(wav, 4)); + } + + [Theory] + [MemberData(nameof(AllSounds))] + public void Sounds_are_short_quiet_and_not_silent(UiSound sound) + { + var samples = UiSoundSynth.Samples(sound); + var seconds = (double)samples.Length / UiSoundSynth.SampleRate; + Assert.InRange(seconds, 0.03, 0.25); + var peak = samples.Max(Math.Abs); + Assert.InRange(peak, 0.05, 0.5); // audible, but never loud over a TV + } + + [Theory] + [MemberData(nameof(AllSounds))] + public void Sounds_start_and_end_at_zero_so_they_do_not_click(UiSound sound) + { + var samples = UiSoundSynth.Samples(sound); + Assert.InRange(Math.Abs(samples[0]), 0f, 0.01f); + Assert.InRange(Math.Abs(samples[^1]), 0f, 0.01f); + } + + [Fact] + public void The_three_sounds_are_different() + { + var move = UiSoundSynth.Wav(UiSound.Move); + var confirm = UiSoundSynth.Wav(UiSound.Confirm); + var back = UiSoundSynth.Wav(UiSound.Back); + Assert.NotEqual(move, confirm); + Assert.NotEqual(confirm, back); + Assert.NotEqual(move, back); + } +}