From cba8966e27317d76f6556b4b94068c1d2d074ab8 Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Wed, 30 Sep 2026 16:07:13 -0300 Subject: [PATCH 1/5] feat: redesigned session menu that also opens outside a session (preview) The Select + Y menu is the core of the app, so it gets the same language as the console interface. Look and motion - Acrylic glass (DesktopAcrylicBackdrop) with rounded corners (DWM), icon tiles like the console quick settings, a volume gauge, a filled main exit. - The panel slides up and fades in; the tiles come in 45 ms apart; closing fades and sinks it, then closes (by timer, so a stalled composition can never leave the window up). The sub-picker fades and rises in. All of it is skipped when Windows animations are off. - The focused tile outlines in white and grows, on the composition visual: once a tile's visual is used for the entrance, WinUI refuses UIElement.Scale / CenterPoint on it (that threw in the first build). - Move / confirm / back sounds; the arrows go through GamepadNavigator (aligned card, then the nearest), and the picker confines the focus to itself. Preview (this alpha) - The menu opens without a session (shortcut, tray, consolemode://menu) on the game screen if it is on, else the primary one, with a "Preview" badge. FPS (needs a restore afterwards) is hidden, "Back to the PC" becomes "Close menu" and Exit only exits the app: nothing tries to restore a session that does not exist. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.en-US.md | 1 + CHANGELOG.md | 1 + docs/TESTING.md | 10 + src/ConsoleMode/App.xaml.cs | 3 +- src/ConsoleMode/Resources/Strings.en-US.json | 2 + src/ConsoleMode/Resources/Strings.es-ES.json | 2 + src/ConsoleMode/Resources/Strings.pt-BR.json | 2 + .../Services/LocalizationService.cs | 2 + src/ConsoleMode/SessionMenuWindow.xaml | 208 +++++++++------ src/ConsoleMode/SessionMenuWindow.xaml.cs | 243 +++++++++++++++++- .../ViewModels/MainViewModel.SessionMenu.cs | 56 +++- 11 files changed, 419 insertions(+), 111 deletions(-) diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index c6343b8..991f935 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -4,6 +4,7 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ## [Unreleased] ### What's new +- Session menu (the controller shortcut, Select + Y in the suggestion) redesigned: acrylic glass with rounded corners, icon tiles and a volume gauge, a focus ring that grows, sounds, and animations (the panel slides up and the tiles come in one after another; closing fades it out, and the picker animates too). In this alpha it also opens **outside a session**, as a preview with a badge on top, so it can be tried without turning on console mode (in that preview the FPS limit is hidden and "Back to the PC" becomes "Close menu"). (#120) - 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) diff --git a/CHANGELOG.md b/CHANGELOG.md index fd1d373..9355d23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ## [Unreleased] ### Novidades +- Menu da sessão (o atalho do controle, Select + Y na sugestão) redesenhado: vidro acrílico com cantos arredondados, blocos com ícone e medidor de volume, anel de foco que cresce, sons, e animações (o painel sobe e os blocos entram em cascata; ao fechar ele some suavemente, e o seletor também anima). Nesta alpha ele abre também **fora da sessão**, como prévia, com um selo no topo, para testar sem ligar o modo console (nessa prévia o limite de FPS não aparece e "Voltar ao PC" vira "Fechar menu"). (#120) - 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) diff --git a/docs/TESTING.md b/docs/TESTING.md index 9d08144..258ff8b 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -112,6 +112,16 @@ Pré-condições: Ajustes → "Entrar ao conectar um controle" **ligado**; app n ## 1c. Menu da sessão (atalho sugerido: Select + Y) +### Prévia fora da sessão (alpha) e visual novo + +- [ ] Com o atalho do menu definido e **sem** sessão ativa, segurar o atalho abre o menu na tela de jogo (ou na principal, se a da TV estiver desligada), com o selo "Prévia · fora da sessão". O link `consolemode://menu` e o item da bandeja também abrem. Resultado: ______ +- [ ] Na prévia: o bloco de FPS não aparece, "Voltar ao PC" vira "Fechar menu" e só fecha o menu, e "Sair do Console Mode" fecha o app sem restaurar nada. Resultado: ______ +- [ ] Volume (A, ◀/▶, X muda o mudo), saída de áudio, resolução e HDR funcionam na prévia e valem de verdade (não há sessão para desfazê-los). Resultado: ______ +- [ ] O painel sobe e aparece, os blocos entram em cascata (~45 ms entre cada), e ao fechar (B, Esc ou o atalho) ele some em ~0,2 s. Com as animações do Windows desligadas, abre e fecha sem movimento. Resultado: ______ +- [ ] O bloco em foco cresce e ganha contorno branco; há som ao mover, confirmar e voltar; o seletor (resolução, áudio, FPS) anima ao abrir e já vem com a opção atual em foco. Resultado: ______ +- [ ] Cima/baixo/esquerda/direita nunca ficam "mortos" entre os blocos e as duas saídas; com o seletor aberto o foco não escapa para os blocos de trás. Resultado: ______ +- [ ] O fundo é vidro fosco com cantos arredondados; se o Windows não aplicar o efeito, o menu continua escuro e legível. Resultado: ______ + 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: ______ diff --git a/src/ConsoleMode/App.xaml.cs b/src/ConsoleMode/App.xaml.cs index 582cf2d..0ff4335 100644 --- a/src/ConsoleMode/App.xaml.cs +++ b/src/ConsoleMode/App.xaml.cs @@ -211,7 +211,8 @@ private void RefreshGuideWatch() // 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); + // The menu also opens outside a session in this alpha (a preview, to try it without console mode). + Apply(_menuChord, idle && _menuChord.Mask != 0); Apply(_exitChord, idle && _exitChord.Mask != 0 && ViewModel.IsConsoleActive); } diff --git a/src/ConsoleMode/Resources/Strings.en-US.json b/src/ConsoleMode/Resources/Strings.en-US.json index 0c2df58..cde0889 100644 --- a/src/ConsoleMode/Resources/Strings.en-US.json +++ b/src/ConsoleMode/Resources/Strings.en-US.json @@ -86,6 +86,8 @@ "HintChange": "Change", "ConsoleSettingsHint": "Press A on a row to change its value. Everything is saved right away.", "BackToPc": "Back to the PC", + "CloseMenu": "Close menu", + "SessionPreviewBadge": "Preview · outside a session", "ConsoleActiveHint": "Or hold {0} on the controller for 1 second.", "ChooseRoleFor": "What does the {0} screen do?", "HomeButtonCard": "Controller shortcut to open Console Mode", diff --git a/src/ConsoleMode/Resources/Strings.es-ES.json b/src/ConsoleMode/Resources/Strings.es-ES.json index d4b4236..9d86639 100644 --- a/src/ConsoleMode/Resources/Strings.es-ES.json +++ b/src/ConsoleMode/Resources/Strings.es-ES.json @@ -86,6 +86,8 @@ "HintChange": "Cambiar", "ConsoleSettingsHint": "Pulsa A en una fila para cambiar su valor. Todo se guarda al momento.", "BackToPc": "Volver al PC", + "CloseMenu": "Cerrar menú", + "SessionPreviewBadge": "Vista previa · fuera de la sesión", "ConsoleActiveHint": "O mantén {0} en el mando durante 1 segundo.", "ChooseRoleFor": "¿Qué hace la pantalla {0}?", "HomeButtonCard": "Atajo del mando para abrir Console Mode", diff --git a/src/ConsoleMode/Resources/Strings.pt-BR.json b/src/ConsoleMode/Resources/Strings.pt-BR.json index 98bf658..68b5e30 100644 --- a/src/ConsoleMode/Resources/Strings.pt-BR.json +++ b/src/ConsoleMode/Resources/Strings.pt-BR.json @@ -86,6 +86,8 @@ "HintChange": "Alterar", "ConsoleSettingsHint": "Aperte A numa linha para trocar o valor. Tudo é salvo na hora.", "BackToPc": "Voltar ao PC", + "CloseMenu": "Fechar menu", + "SessionPreviewBadge": "Prévia · fora da sessão", "ConsoleActiveHint": "Ou segure {0} no controle por 1 segundo.", "ChooseRoleFor": "O que a tela {0} faz?", "HomeButtonCard": "Atalho do controle para abrir o Console Mode", diff --git a/src/ConsoleMode/Services/LocalizationService.cs b/src/ConsoleMode/Services/LocalizationService.cs index 96d759a..494803d 100644 --- a/src/ConsoleMode/Services/LocalizationService.cs +++ b/src/ConsoleMode/Services/LocalizationService.cs @@ -187,6 +187,8 @@ internal void NotifyAll() public string HomeButtonCard => LocalizationService.Get(nameof(HomeButtonCard)); public string ExitShortcutCard => LocalizationService.Get(nameof(ExitShortcutCard)); public string SoundsCard => LocalizationService.Get(nameof(SoundsCard)); + public string SessionPreviewBadge => LocalizationService.Get(nameof(SessionPreviewBadge)); + public string CloseMenu => LocalizationService.Get(nameof(CloseMenu)); public string BackgroundCard => LocalizationService.Get(nameof(BackgroundCard)); public string BackgroundDescription => LocalizationService.Get(nameof(BackgroundDescription)); public string BackgroundImageCard => LocalizationService.Get(nameof(BackgroundImageCard)); diff --git a/src/ConsoleMode/SessionMenuWindow.xaml b/src/ConsoleMode/SessionMenuWindow.xaml index 032615c..50c844b 100644 --- a/src/ConsoleMode/SessionMenuWindow.xaml +++ b/src/ConsoleMode/SessionMenuWindow.xaml @@ -8,8 +8,8 @@ xmlns:controls="using:ConsoleMode.Controls" Title="Console Mode"> - - + + + @@ -56,95 +59,134 @@ - - - - - - - - + + + + + + + + + + + + + + + + + - + - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/ConsoleMode/SessionMenuWindow.xaml.cs b/src/ConsoleMode/SessionMenuWindow.xaml.cs index ec6e984..583d55f 100644 --- a/src/ConsoleMode/SessionMenuWindow.xaml.cs +++ b/src/ConsoleMode/SessionMenuWindow.xaml.cs @@ -66,7 +66,7 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target) { Sounds = true, // Focus may only land inside the picker while it is open (it covers the tiles). - SearchRoot = () => ViewModel.IsSessionPickerOpen ? PickerCard : Root, + SearchRoot = () => ViewModel.IsSessionSwitcherOpen ? SwitcherCard : ViewModel.IsSessionPickerOpen ? PickerCard : Root, // Adjust mode on the volume tile: Left/Right change it, Up/Down are swallowed. BeforeMove = direction => { @@ -76,7 +76,11 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target) return true; } }; - _navigator.OptionRequested += () => { if (IsVolumeFocused()) ViewModel.ToggleMuteCommand.Execute(null); }; + _navigator.OptionRequested += () => + { + if (ViewModel.IsSessionSwitcherOpen) CloseFocusedWindow(); + else if (IsVolumeFocused()) ViewModel.ToggleMuteCommand.Execute(null); + }; _navigator.BackRequested += () => { if (_editingVolume) SetEditingVolume(false); @@ -103,6 +107,8 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target) _navigator.Navigate(direction); return; } + // Delete closes the highlighted window in the switcher (the keyboard's X / Square). + if (e.Key == Windows.System.VirtualKey.Delete && ViewModel.IsSessionSwitcherOpen) { e.Handled = true; CloseFocusedWindow(); return; } // The pad's B comes from the navigator's polling; handling GamepadB here too would count it twice. if (e.Key is not Windows.System.VirtualKey.Escape) return; e.Handled = true; @@ -266,22 +272,22 @@ public void CloseAnimated() _closeTimer.Start(); } - /// The sub-picker fades and rises in over the tiles. - private void PlayPickerEntrance() + /// A panel over the tiles (the picker, the window switcher): the veil fades in, the card fades and rises in. + private void PlayOverlayEntrance(UIElement overlay, UIElement card) { if (!Animate) return; try { - ElementCompositionPreview.GetElementVisual(PickerOverlay).Opacity = 0f; - Enter(PickerOverlay, 0f, 200, 0); - ElementCompositionPreview.GetElementVisual(PickerCard).Opacity = 0f; - Enter(PickerCard, 22f, 240, 30); + ElementCompositionPreview.GetElementVisual(overlay).Opacity = 0f; + Enter(overlay, 0f, 200, 0); + ElementCompositionPreview.GetElementVisual(card).Opacity = 0f; + Enter(card, 22f, 240, 30); } catch (Exception ex) { AppLog.Write($"Menu da sessão: animação: {ex.Message}"); - ElementCompositionPreview.GetElementVisual(PickerOverlay).Opacity = 1f; - ElementCompositionPreview.GetElementVisual(PickerCard).Opacity = 1f; + ElementCompositionPreview.GetElementVisual(overlay).Opacity = 1f; + ElementCompositionPreview.GetElementVisual(card).Opacity = 1f; } } @@ -354,13 +360,44 @@ private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e) case nameof(MainViewModel.IsSessionPickerOpen): DispatcherQueue.TryEnqueue(() => { - if (ViewModel.IsSessionPickerOpen) { PlayPickerEntrance(); FocusPickerSelection(); } + if (ViewModel.IsSessionPickerOpen) { PlayOverlayEntrance(PickerOverlay, PickerCard); FocusPickerSelection(); } + else FirstRow.Focus(FocusState.Keyboard); + }); + break; + case nameof(MainViewModel.IsSessionSwitcherOpen): + DispatcherQueue.TryEnqueue(() => + { + if (ViewModel.IsSessionSwitcherOpen) { PlayOverlayEntrance(SwitcherOverlay, SwitcherCard); FocusSwitcherCard(); } else FirstRow.Focus(FocusState.Keyboard); }); break; } } + /// X / Delete in the switcher: ask the highlighted window to close. + private void CloseFocusedWindow() + { + if (Root.XamlRoot is not { } root) return; + if (FocusManager.GetFocusedElement(root) is FrameworkElement { DataContext: SwitchWindowItem item }) + _ = ViewModel.CloseSwitcherWindowAsync(item); + } + + /// Opens on the second card (the first is the window that was in front), like Alt + Tab. + private void FocusSwitcherCard() + { + var index = AltTabRules.InitialIndex(ViewModel.SwitcherWindows.Count); + bool TryFocus() => + SwitcherList.ContainerFromIndex(index) is { } container + && (FocusManager.FindFirstFocusableElement(container) as Control)?.Focus(FocusState.Keyboard) == true; + if (ViewModel.SwitcherWindows.Count == 0 || TryFocus()) return; + void OnLayout(object? s, object e) + { + SwitcherList.LayoutUpdated -= OnLayout; + TryFocus(); + } + SwitcherList.LayoutUpdated += OnLayout; + } + private void FocusPickerSelection() { var index = Math.Max(ViewModel.SessionPickerOptions.ToList().FindIndex(o => o.IsSelected), 0); diff --git a/src/ConsoleMode/ViewModels/MainViewModel.ConsoleUi.cs b/src/ConsoleMode/ViewModels/MainViewModel.ConsoleUi.cs index e6bff30..2f207ae 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.ConsoleUi.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.ConsoleUi.cs @@ -30,6 +30,7 @@ public partial class MainViewModel public string HintConfirm => IsPlayStationHints ? "✕" : "A"; public string HintBack => IsPlayStationHints ? "○" : "B"; public string HintAlt => IsPlayStationHints ? "△" : "Y"; + public string HintOption => IsPlayStationHints ? "□" : "X"; public string HintMenu => IsPlayStationHints ? "OPTIONS" : "☰"; partial void OnIsConsoleUiChanged(bool value) @@ -48,6 +49,7 @@ partial void OnIsPlayStationHintsChanged(bool value) OnPropertyChanged(nameof(HintConfirm)); OnPropertyChanged(nameof(HintBack)); OnPropertyChanged(nameof(HintAlt)); + OnPropertyChanged(nameof(HintOption)); OnPropertyChanged(nameof(HintMenu)); OnPropertyChanged(nameof(HintLb)); OnPropertyChanged(nameof(HintRb)); diff --git a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs index 5e3aad1..c2ce5aa 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs @@ -111,13 +111,15 @@ private async Task ShowSessionHintAsync() private void CloseSessionMenu() { IsSessionPickerOpen = false; + IsSessionSwitcherOpen = false; _sessionMenu?.CloseAnimated(); } - /// B: closes the picker first, then the menu. + /// B: closes the window switcher or the picker first, then the menu. public void SessionMenuBack() { - if (IsSessionPickerOpen) IsSessionPickerOpen = false; + if (IsSessionSwitcherOpen) IsSessionSwitcherOpen = false; + else if (IsSessionPickerOpen) IsSessionPickerOpen = false; else CloseSessionMenu(); } @@ -174,6 +176,7 @@ private async Task LoadSessionValuesAsync() SessionAudioText = audioName; VolumePercent = volume ?? -1; SessionFpsText = state.FpsLimit > 0 ? $"{state.FpsLimit} FPS" : LocalizationService.Get("FpsNoLimit"); + SessionWindowsText = LocalizationService.Get("SessionWindowsCount", await Task.Run(() => WindowSwitcher.List().Count)); OnPropertyChanged(nameof(IsFpsMenuAvailable)); } diff --git a/src/ConsoleMode/ViewModels/MainViewModel.Switcher.cs b/src/ConsoleMode/ViewModels/MainViewModel.Switcher.cs new file mode 100644 index 0000000..96e222b --- /dev/null +++ b/src/ConsoleMode/ViewModels/MainViewModel.Switcher.cs @@ -0,0 +1,96 @@ +using System.Collections.ObjectModel; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ConsoleMode.Native; +using ConsoleMode.Services; +using Microsoft.UI.Xaml.Media; +using Microsoft.UI.Xaml.Media.Imaging; + +namespace ConsoleMode.ViewModels; + +/// One card of the window switcher in the session menu. +public sealed partial class SwitchWindowItem : ObservableObject +{ + public SwitchWindowItem(SwitchWindow window) => Window = window; + + public SwitchWindow Window { get; } + public string Title => Window.Title; + public string ProcessName => Window.ProcessName; + + /// The program's icon, loaded after the card shows; the card draws a generic one until then. + [ObservableProperty] private ImageSource? _icon; +} + +// The "Alt + Tab" inside the session menu: a strip of the open windows, picked with the pad. +public partial class MainViewModel +{ + public ObservableCollection SwitcherWindows { get; } = []; + + [ObservableProperty] private bool _isSessionSwitcherOpen; + [ObservableProperty] private string _sessionWindowsText = ""; + + public bool SwitcherIsEmpty => SwitcherWindows.Count == 0; + + [RelayCommand] + private async Task OpenSessionSwitcherAsync() + { + if (IsSessionMenuBusy) return; + await RefreshSwitcherAsync(); + IsSessionSwitcherOpen = true; + } + + /// Lists the windows off the UI thread, then fills the strip and starts loading the icons. + private async Task RefreshSwitcherAsync() + { + var windows = await Task.Run(WindowSwitcher.List); + SwitcherWindows.Clear(); + foreach (var window in windows) SwitcherWindows.Add(new SwitchWindowItem(window)); + SessionWindowsText = LocalizationService.Get("SessionWindowsCount", windows.Count); + OnPropertyChanged(nameof(SwitcherIsEmpty)); + foreach (var item in SwitcherWindows.ToList()) _ = LoadIconAsync(item); + } + + private static async Task LoadIconAsync(SwitchWindowItem item) + { + if (item.Window.ExePath is not { } path) return; + try + { + var file = await Windows.Storage.StorageFile.GetFileFromPathAsync(path); + using var thumbnail = await file.GetThumbnailAsync(Windows.Storage.FileProperties.ThumbnailMode.SingleItem, 64); + if (thumbnail is null) return; + var image = new BitmapImage(); + await image.SetSourceAsync(thumbnail); + item.Icon = image; + } + catch + { + // Protected folders (packaged apps) refuse: the generic icon stays. + } + } + + /// A on a card: bring that window to the front and leave the menu. + [RelayCommand] + private void SwitchToWindow(SwitchWindowItem? item) + { + if (item is null) return; + var ok = WindowSwitcher.Activate(item.Window.Handle); + AppLog.Write($"Janelas: trocar para \"{item.Title}\" ({item.ProcessName}) => {(ok ? "ok" : "não conseguiu")}"); + IsSessionSwitcherOpen = false; + CloseSessionMenu(); + } + + /// X on a card: ask the window to close, then drop its card once it is gone. + public async Task CloseSwitcherWindowAsync(SwitchWindowItem item) + { + WindowSwitcher.RequestClose(item.Window.Handle); + AppLog.Write($"Janelas: fechar \"{item.Title}\" ({item.ProcessName})"); + // Give it a moment: a window that asks to save stays, and so does its card. + await Task.Delay(450); + if (!WindowSwitcher.Exists(item.Window.Handle) || !WindowSwitcher.List().Any(w => w.Handle == item.Window.Handle)) + { + SwitcherWindows.Remove(item); + SessionWindowsText = LocalizationService.Get("SessionWindowsCount", SwitcherWindows.Count); + OnPropertyChanged(nameof(SwitcherIsEmpty)); + } + } +} diff --git a/tests/ConsoleMode.Tests/AltTabRulesTests.cs b/tests/ConsoleMode.Tests/AltTabRulesTests.cs new file mode 100644 index 0000000..325c680 --- /dev/null +++ b/tests/ConsoleMode.Tests/AltTabRulesTests.cs @@ -0,0 +1,59 @@ +using ConsoleMode.Services; + +namespace ConsoleMode.Tests; + +public class AltTabRulesTests +{ + private static WindowFacts Normal(string title = "Discord") => + new(title, "Chrome_WidgetWin_1", Visible: true, Cloaked: false, HasOwner: false, IsToolWindow: false, IsAppWindow: false, IsOwnProcess: false); + + [Fact] + public void An_ordinary_visible_titled_window_is_listed() => + Assert.True(AltTabRules.ShouldList(Normal())); + + [Fact] + public void Invisible_cloaked_untitled_and_our_own_windows_are_not_listed() + { + Assert.False(AltTabRules.ShouldList(Normal() with { Visible = false })); + Assert.False(AltTabRules.ShouldList(Normal() with { Cloaked = true })); // a suspended UWP app + Assert.False(AltTabRules.ShouldList(Normal(title: ""))); + Assert.False(AltTabRules.ShouldList(Normal(title: " "))); + Assert.False(AltTabRules.ShouldList(Normal() with { IsOwnProcess = true })); // the menu itself + } + + [Theory] + [InlineData("Progman")] + [InlineData("WorkerW")] + [InlineData("Shell_TrayWnd")] + [InlineData("Shell_SecondaryTrayWnd")] + public void The_desktop_and_the_taskbar_are_not_listed(string className) => + Assert.False(AltTabRules.ShouldList(Normal("Program Manager") with { ClassName = className })); + + [Fact] + public void Tool_windows_and_owned_dialogs_are_skipped_unless_they_say_they_are_app_windows() + { + Assert.False(AltTabRules.ShouldList(Normal() with { IsToolWindow = true })); + Assert.False(AltTabRules.ShouldList(Normal() with { HasOwner = true })); + Assert.True(AltTabRules.ShouldList(Normal() with { IsToolWindow = true, IsAppWindow = true })); + Assert.True(AltTabRules.ShouldList(Normal() with { HasOwner = true, IsAppWindow = true })); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(1, 0)] + [InlineData(2, 1)] + [InlineData(9, 1)] + public void The_switcher_opens_on_the_second_card_like_alt_tab(int count, int expected) => + Assert.Equal(expected, AltTabRules.InitialIndex(count)); + + [Theory] + [InlineData("chrome.exe", "Chrome")] + [InlineData("Discord.EXE", "Discord")] + [InlineData("steamwebhelper", "Steamwebhelper")] + [InlineData("a.exe", "A")] + [InlineData("", "")] + [InlineData(".exe", "")] + [InlineData(null, "")] + public void Process_names_are_shown_without_the_extension(string? input, string expected) => + Assert.Equal(expected, AltTabRules.ProcessDisplayName(input)); +} diff --git a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj index e3da692..3008210 100644 --- a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj +++ b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj @@ -36,6 +36,7 @@ + From dec3f9409990f3685a54d05f6922150d3c775122 Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Wed, 30 Sep 2026 16:26:02 -0300 Subject: [PATCH 3/5] feat: full-screen session menu in the style of Steam's Shift + Tab overlay The session menu was a fixed-size panel in the middle of the game screen, so on a big display it looked small. It now covers the whole screen, like Steam's overlay: the game dims and blurs behind it, the options sit in a glass side panel and the open windows fill the middle. - WindowPlacement.FillScreen puts the borderless, topmost window over the whole monitor (the game screen in a session, the primary one in the preview). - Side panel (left): back to the game, volume with a gauge, resolution, audio, FPS, HDR, the coming-soon recording row, then the two ways out, the main one filled. Rows grow 2.5% when focused; the window cards 6%. - The open windows are always on screen (the "Windows" tile and the separate strip are gone): the in-menu Alt + Tab is a grid with one card per window. The D-pad moves between the panel and the grid, A brings a window to the front, X / Delete closes it, B goes back to the game. - GamepadNavigator.NearestOnSides lets left/right jump to the nearest control when nothing shares the row: the panel and the grid are side by side and never do. - Entrance: the header drops in, the panel slides in from the left with its rows one after another, then the windows and the hints; closing fades and sinks it. All skipped when Windows animations are off. Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.en-US.md | 2 +- CHANGELOG.md | 2 +- docs/TESTING.md | 24 +- src/ConsoleMode/Native/WindowPlacement.cs | 12 + src/ConsoleMode/Resources/Strings.en-US.json | 1 - src/ConsoleMode/Resources/Strings.es-ES.json | 1 - src/ConsoleMode/Resources/Strings.pt-BR.json | 1 - src/ConsoleMode/Services/AltTabRules.cs | 6 - src/ConsoleMode/Services/GamepadNavigator.cs | 11 +- .../Services/LocalizationService.cs | 1 - src/ConsoleMode/SessionMenuWindow.xaml | 354 +++++++++--------- src/ConsoleMode/SessionMenuWindow.xaml.cs | 172 ++++----- .../ViewModels/MainViewModel.SessionMenu.cs | 8 +- .../ViewModels/MainViewModel.Switcher.cs | 16 +- tests/ConsoleMode.Tests/AltTabRulesTests.cs | 8 - 15 files changed, 292 insertions(+), 327 deletions(-) diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index 2400faa..ef38832 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -4,7 +4,7 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ## [Unreleased] ### What's new -- Session menu (the controller shortcut, Select + Y in the suggestion) redesigned: acrylic glass with rounded corners, icon tiles and a volume gauge, a focus ring that grows, sounds, and animations (the panel slides up and the tiles come in one after another; closing fades it out, and the picker animates too). In this alpha it also opens **outside a session**, as a preview with a badge on top, so it can be tried without turning on console mode (in that preview the FPS limit is hidden and "Back to the PC" becomes "Close menu"). The menu also gets an in-menu Alt + Tab: the **Windows** tile opens a strip of the open windows (icon and title); the D-pad picks, A brings the window to the front, X closes it and B goes back; switching windows does not end the session. (#120) +- Session menu (the controller shortcut, Select + Y in the suggestion) redesigned like Steam's overlay (Shift + Tab): it covers the whole screen, the game dims and blurs behind it, a side panel gathers the options (volume with a gauge, resolution, audio, FPS, HDR) and the **open windows** are always visible in the middle, an in-menu Alt + Tab: the D-pad picks, A brings the window to the front, X closes it and B goes back to the game (switching windows does not end the session). It has a focus that grows, sounds and animations (the panel slides in, the rows follow one after another and closing fades it out). In this alpha it also opens **outside a session**, as a preview with a badge on top, so it can be tried without turning on console mode (in it the FPS limit is hidden and "Back to the PC" becomes "Close menu"). (#120) - 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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6b931cf..2e757a7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ## [Unreleased] ### Novidades -- Menu da sessão (o atalho do controle, Select + Y na sugestão) redesenhado: vidro acrílico com cantos arredondados, blocos com ícone e medidor de volume, anel de foco que cresce, sons, e animações (o painel sobe e os blocos entram em cascata; ao fechar ele some suavemente, e o seletor também anima). Nesta alpha ele abre também **fora da sessão**, como prévia, com um selo no topo, para testar sem ligar o modo console (nessa prévia o limite de FPS não aparece e "Voltar ao PC" vira "Fechar menu"). O menu ganhou também um ALT+TAB interno: o bloco **Janelas** abre uma faixa com as janelas abertas (ícone e título), o direcional escolhe, A traz a janela para a frente, X a fecha e B volta; trocar de janela não encerra a sessão. (#120) +- Menu da sessão (o atalho do controle, Select + Y na sugestão) redesenhado como o overlay do Steam (Shift+Tab): cobre a tela inteira, o jogo escurece e desfoca por trás, um painel lateral reúne as opções (volume com medidor, resolução, áudio, FPS, HDR) e no meio ficam sempre visíveis as **janelas abertas**, um ALT+TAB interno: o direcional escolhe, A traz a janela para a frente, X a fecha e B volta ao jogo (trocar de janela não encerra a sessão). Tem foco que cresce, sons e animações (o painel entra deslizando, as linhas vêm em cascata e ao fechar ele some suavemente). Nesta alpha ele abre também **fora da sessão**, como prévia com um selo no topo, para testar sem ligar o modo console (nela o limite de FPS não aparece e "Voltar ao PC" vira "Fechar menu"). (#120) - 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) diff --git a/docs/TESTING.md b/docs/TESTING.md index 562d7b4..82cff5c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -112,18 +112,18 @@ Pré-condições: Ajustes → "Entrar ao conectar um controle" **ligado**; app n ## 1c. Menu da sessão (atalho sugerido: Select + Y) -### Prévia fora da sessão (alpha) e visual novo - -- [ ] Com o atalho do menu definido e **sem** sessão ativa, segurar o atalho abre o menu na tela de jogo (ou na principal, se a da TV estiver desligada), com o selo "Prévia · fora da sessão". O link `consolemode://menu` e o item da bandeja também abrem. Resultado: ______ -- [ ] Na prévia: o bloco de FPS não aparece, "Voltar ao PC" vira "Fechar menu" e só fecha o menu, e "Sair do Console Mode" fecha o app sem restaurar nada. Resultado: ______ -- [ ] Volume (A, ◀/▶, X muda o mudo), saída de áudio, resolução e HDR funcionam na prévia e valem de verdade (não há sessão para desfazê-los). Resultado: ______ -- [ ] O painel sobe e aparece, os blocos entram em cascata (~45 ms entre cada), e ao fechar (B, Esc ou o atalho) ele some em ~0,2 s. Com as animações do Windows desligadas, abre e fecha sem movimento. Resultado: ______ -- [ ] O bloco em foco cresce e ganha contorno branco; há som ao mover, confirmar e voltar; o seletor (resolução, áudio, FPS) anima ao abrir e já vem com a opção atual em foco. Resultado: ______ -- [ ] Cima/baixo/esquerda/direita nunca ficam "mortos" entre os blocos e as duas saídas; com o seletor aberto o foco não escapa para os blocos de trás. Resultado: ______ -- [ ] Bloco **Janelas** ("N abertas"): A abre uma faixa com as janelas abertas (ícone, programa e título), em ordem de frente para trás, já com o foco na segunda (como o Alt+Tab). A traz a janela escolhida para a frente (restaura se estiver minimizada) e fecha o menu; X (□ no PlayStation) ou Delete pede para fechar a janela e o card some quando ela fecha; B volta ao menu. Resultado: ______ -- [ ] A faixa não lista o próprio menu, a barra de tarefas, a área de trabalho nem apps UWP suspensos; com nenhuma janela mostra "Nenhuma janela aberta". Resultado: ______ -- [ ] Em sessão (Big Picture/Playnite aberto): trocar para outra janela **não** restaura a mesa, e voltar ao Big Picture pela faixa funciona; uma janela aberta na tela de jogo aparece na lista. Resultado: ______ -- [ ] O fundo é vidro fosco com cantos arredondados; se o Windows não aplicar o efeito, o menu continua escuro e legível. Resultado: ______ +### Tela cheia estilo Steam (Shift+Tab), prévia e janelas + +- [ ] O menu cobre a **tela inteira** do monitor do jogo (ou o principal, na prévia), com o jogo escurecido e desfocado por trás: painel lateral à esquerda com as opções e, no meio, o bloco "Janelas". Resultado: ______ +- [ ] Com o atalho do menu definido e **sem** sessão ativa, segurar o atalho abre o menu (prévia), com o selo "Prévia · fora da sessão". O link `consolemode://menu` e o item da bandeja também abrem. Resultado: ______ +- [ ] Na prévia: o FPS não aparece, "Voltar ao PC" vira "Fechar menu" e só fecha o menu, e "Sair do Console Mode" fecha o app sem restaurar nada. Volume, saída de áudio, resolução e HDR valem de verdade (não há sessão para desfazê-los). Resultado: ______ +- [ ] O painel lateral entra deslizando pela esquerda com as linhas em cascata, as janelas sobem e aparecem, e ao fechar (B, Esc ou o atalho) tudo some em ~0,2 s. Com as animações do Windows desligadas, abre e fecha sem movimento. Resultado: ______ +- [ ] A linha/card em foco ganha contorno branco e cresce um pouco; há som ao mover, confirmar e voltar. Resultado: ______ +- [ ] Direcional entre o painel lateral e o grid de janelas (esquerda/direita) e dentro de cada um (cima/baixo) nunca fica "morto"; com o seletor (resolução, áudio, FPS) aberto o foco não escapa para trás. Resultado: ______ +- [ ] **Janelas**: lista as janelas abertas (ícone, programa e título) em ordem de frente para trás. A traz a escolhida para a frente (restaura se estiver minimizada) e fecha o menu; X (□ no PlayStation) ou Delete pede para a janela fechar e o card some quando ela fecha; uma que pergunta "salvar?" mantém o card. Resultado: ______ +- [ ] A lista não mostra o próprio menu, a barra de tarefas, a área de trabalho nem apps UWP suspensos; sem janelas mostra "Nenhuma janela aberta". Resultado: ______ +- [ ] Em sessão (Big Picture/Playnite aberto): trocar para outra janela **não** restaura a mesa, e voltar ao Big Picture pelo grid funciona; uma janela aberta na tela de jogo aparece na lista. Resultado: ______ +- [ ] Monitor com escala de 150% ou 200% (a TV 4K): nada fica cortado nem minúsculo. Resultado: ______ 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. diff --git a/src/ConsoleMode/Native/WindowPlacement.cs b/src/ConsoleMode/Native/WindowPlacement.cs index 7f6c936..9e57310 100644 --- a/src/ConsoleMode/Native/WindowPlacement.cs +++ b/src/ConsoleMode/Native/WindowPlacement.cs @@ -25,6 +25,18 @@ public static void CenterOn(AppWindow appWindow, ScreenRect? target, double widt appWindow.MoveAndResize(new Windows.Graphics.RectInt32(cx - w / 2, cy - h / 2, w, h)); } + /// Covers the whole screen (an overlay like Steam's Shift + Tab). With no target, the primary display. + public static void FillScreen(AppWindow appWindow, ScreenRect? target) + { + if (target is null || target.Width <= 0 || target.Height <= 0) + { + var area = Microsoft.UI.Windowing.DisplayArea.GetFromWindowId(appWindow.Id, Microsoft.UI.Windowing.DisplayAreaFallback.Primary).OuterBounds; + appWindow.MoveAndResize(area); + return; + } + appWindow.MoveAndResize(new Windows.Graphics.RectInt32(target.X, target.Y, target.Width, target.Height)); + } + /// Top-right corner of the screen, in from the edges. public static void TopRightOn(AppWindow appWindow, ScreenRect? target, double widthDip, double heightDip, double marginDip) { diff --git a/src/ConsoleMode/Resources/Strings.en-US.json b/src/ConsoleMode/Resources/Strings.en-US.json index 3504cd4..860fdaa 100644 --- a/src/ConsoleMode/Resources/Strings.en-US.json +++ b/src/ConsoleMode/Resources/Strings.en-US.json @@ -89,7 +89,6 @@ "CloseMenu": "Close menu", "SessionWindowsCard": "Windows", "SessionWindowsCount": "{0} open", - "SwitcherTitle": "Switch window", "SwitcherEmpty": "No open windows", "SwitcherOpen": "Open", "SwitcherCloseWindow": "Close window", diff --git a/src/ConsoleMode/Resources/Strings.es-ES.json b/src/ConsoleMode/Resources/Strings.es-ES.json index 84f5784..507d41f 100644 --- a/src/ConsoleMode/Resources/Strings.es-ES.json +++ b/src/ConsoleMode/Resources/Strings.es-ES.json @@ -89,7 +89,6 @@ "CloseMenu": "Cerrar menú", "SessionWindowsCard": "Ventanas", "SessionWindowsCount": "{0} abiertas", - "SwitcherTitle": "Cambiar de ventana", "SwitcherEmpty": "No hay ventanas abiertas", "SwitcherOpen": "Abrir", "SwitcherCloseWindow": "Cerrar ventana", diff --git a/src/ConsoleMode/Resources/Strings.pt-BR.json b/src/ConsoleMode/Resources/Strings.pt-BR.json index cc0af43..c7fe898 100644 --- a/src/ConsoleMode/Resources/Strings.pt-BR.json +++ b/src/ConsoleMode/Resources/Strings.pt-BR.json @@ -89,7 +89,6 @@ "CloseMenu": "Fechar menu", "SessionWindowsCard": "Janelas", "SessionWindowsCount": "{0} abertas", - "SwitcherTitle": "Trocar de janela", "SwitcherEmpty": "Nenhuma janela aberta", "SwitcherOpen": "Abrir", "SwitcherCloseWindow": "Fechar janela", diff --git a/src/ConsoleMode/Services/AltTabRules.cs b/src/ConsoleMode/Services/AltTabRules.cs index 1250816..94ff2ea 100644 --- a/src/ConsoleMode/Services/AltTabRules.cs +++ b/src/ConsoleMode/Services/AltTabRules.cs @@ -26,12 +26,6 @@ public static bool ShouldList(WindowFacts window) return !window.IsToolWindow && !window.HasOwner; } - /// - /// The card to focus when the switcher opens: the second one, like Alt + Tab (the first is the - /// window that was in front, usually the game); the only one when there is just one. - /// - public static int InitialIndex(int count) => count > 1 ? 1 : 0; - /// "chrome.exe" → "Chrome"; empty when there is no name. public static string ProcessDisplayName(string? processName) { diff --git a/src/ConsoleMode/Services/GamepadNavigator.cs b/src/ConsoleMode/Services/GamepadNavigator.cs index 7517cd6..977d3df 100644 --- a/src/ConsoleMode/Services/GamepadNavigator.cs +++ b/src/ConsoleMode/Services/GamepadNavigator.cs @@ -40,6 +40,12 @@ public sealed class GamepadNavigator : IDisposable /// public Func? SearchRoot { get; set; } + /// + /// Left/right may also jump to the nearest control when nothing shares the row: for layouts of + /// side-by-side panels (a sidebar next to a grid), where the two sides are never on the same row. + /// + public bool NearestOnSides { get; set; } + /// Play the interface sounds for moves, confirms and backs (the console interface). public bool Sounds { get; set; } @@ -124,8 +130,9 @@ private bool RevealFocus(Control control) /// The closest control in the direction even when it doesn't overlap the current one's row or column. private Control? FindNearest(FocusNavigationDirection direction) => - // Left/right stay on their own row: jumping to another row from the end of one is confusing. - direction is not (FocusNavigationDirection.Up or FocusNavigationDirection.Down) ? null : + // Left/right stay on their own row (jumping to another row from the end of one is confusing) + // unless the layout is side-by-side panels. + direction is not (FocusNavigationDirection.Up or FocusNavigationDirection.Down) && !NearestOnSides ? null : FocusManager.FindNextElement(direction, new FindNextElementOptions { SearchRoot = CurrentRoot(), diff --git a/src/ConsoleMode/Services/LocalizationService.cs b/src/ConsoleMode/Services/LocalizationService.cs index d6ae9ba..fd82083 100644 --- a/src/ConsoleMode/Services/LocalizationService.cs +++ b/src/ConsoleMode/Services/LocalizationService.cs @@ -190,7 +190,6 @@ internal void NotifyAll() public string SessionPreviewBadge => LocalizationService.Get(nameof(SessionPreviewBadge)); public string CloseMenu => LocalizationService.Get(nameof(CloseMenu)); public string SessionWindowsCard => LocalizationService.Get(nameof(SessionWindowsCard)); - public string SwitcherTitle => LocalizationService.Get(nameof(SwitcherTitle)); public string SwitcherEmpty => LocalizationService.Get(nameof(SwitcherEmpty)); public string SwitcherOpen => LocalizationService.Get(nameof(SwitcherOpen)); public string SwitcherCloseWindow => LocalizationService.Get(nameof(SwitcherCloseWindow)); diff --git a/src/ConsoleMode/SessionMenuWindow.xaml b/src/ConsoleMode/SessionMenuWindow.xaml index b5af1b8..c436d5e 100644 --- a/src/ConsoleMode/SessionMenuWindow.xaml +++ b/src/ConsoleMode/SessionMenuWindow.xaml @@ -8,8 +8,9 @@ xmlns:controls="using:ConsoleMode.Controls" Title="Console Mode"> - - + - - - @@ -81,140 +84,178 @@ - + - - - - - + + + + + + - - + + + + - + + - + - + - + - - + - - + - - - - + - - - + + + + + + + + + + + + + + + + + + + + + + + - + - + + + + + @@ -223,11 +264,11 @@ - + - + @@ -247,57 +288,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + diff --git a/src/ConsoleMode/SessionMenuWindow.xaml.cs b/src/ConsoleMode/SessionMenuWindow.xaml.cs index 583d55f..b496259 100644 --- a/src/ConsoleMode/SessionMenuWindow.xaml.cs +++ b/src/ConsoleMode/SessionMenuWindow.xaml.cs @@ -19,16 +19,15 @@ namespace ConsoleMode; /// -/// The Select + Y menu over the game (see MainViewModel.SessionMenu). Centered on the game -/// screen, always on top, acrylic glass with rounded corners, driven by the controller through -/// GamepadNavigator or by keyboard. It slides up and its tiles come in one after another; closing -/// fades it out. Exclusive-fullscreen games can't be covered; Big Picture and borderless games can. +/// The Select + Y menu over the game (see MainViewModel.SessionMenu), in the spirit of Steam's +/// Shift + Tab: it covers the whole screen, the game dims and blurs behind it, the options sit in +/// a side panel and the open windows (an always-visible Alt + Tab) fill the middle. Driven by the +/// controller through GamepadNavigator or by keyboard. The panel slides in from the left and the +/// rows follow one after another; closing fades it out. Exclusive-fullscreen games can't be +/// covered; Big Picture and borderless games can. /// public sealed partial class SessionMenuWindow : Window { - private const double WidthDip = 1180; - private const double HeightDip = 660; - private static readonly UISettings Ui = new(); private readonly nint _hwnd; @@ -54,10 +53,10 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target) presenter.IsMinimizable = false; presenter.SetBorderAndTitleBar(false, false); } - WindowPlacement.CenterOn(appWindow, target, WidthDip, HeightDip); - TryGlassAndRoundCorners(); + WindowPlacement.FillScreen(appWindow, target); + TryGlass(); - // Hidden until its entrance animation runs, or the tiles would flash in place first. + // Hidden until its entrance animation runs, or the rows would flash in place first. if (Animate) HideForEntrance(); // No foreground gate and HID for PlayStation pads: the game often keeps the focus even @@ -65,9 +64,11 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target) _navigator = new GamepadNavigator(DispatcherQueue, Root, readSonyHid: true) { Sounds = true, - // Focus may only land inside the picker while it is open (it covers the tiles). - SearchRoot = () => ViewModel.IsSessionSwitcherOpen ? SwitcherCard : ViewModel.IsSessionPickerOpen ? PickerCard : Root, - // Adjust mode on the volume tile: Left/Right change it, Up/Down are swallowed. + // The side panel and the windows grid sit next to each other, never on the same row. + NearestOnSides = true, + // Focus may only land inside the picker while it is open (it covers everything else). + SearchRoot = () => ViewModel.IsSessionPickerOpen ? PickerCard : Root, + // Adjust mode on the volume row: Left/Right change it, Up/Down are swallowed. BeforeMove = direction => { if (!_editingVolume) return false; @@ -76,10 +77,10 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target) return true; } }; + // X / Square: close the highlighted window, or mute on the volume row. _navigator.OptionRequested += () => { - if (ViewModel.IsSessionSwitcherOpen) CloseFocusedWindow(); - else if (IsVolumeFocused()) ViewModel.ToggleMuteCommand.Execute(null); + if (!CloseFocusedWindow() && IsVolumeFocused()) ViewModel.ToggleMuteCommand.Execute(null); }; _navigator.BackRequested += () => { @@ -92,7 +93,7 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target) // before they ever bubble up to Root. Root.PreviewKeyDown += (_, e) => { - // Arrows go through the same navigator as the D-pad (aligned card, then the nearest one). + // Arrows go through the same navigator as the D-pad (aligned, then the nearest). var direction = e.Key switch { Windows.System.VirtualKey.Up => FocusNavigationDirection.Up, @@ -107,17 +108,17 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target) _navigator.Navigate(direction); return; } - // Delete closes the highlighted window in the switcher (the keyboard's X / Square). - if (e.Key == Windows.System.VirtualKey.Delete && ViewModel.IsSessionSwitcherOpen) { e.Handled = true; CloseFocusedWindow(); return; } + // Delete closes the highlighted window (the keyboard's X / Square). + if (e.Key == Windows.System.VirtualKey.Delete && CloseFocusedWindow()) { e.Handled = true; return; } // The pad's B comes from the navigator's polling; handling GamepadB here too would count it twice. if (e.Key is not Windows.System.VirtualKey.Escape) return; e.Handled = true; if (_editingVolume) { SetEditingVolume(false); return; } ViewModel.SessionMenuBack(); }; - // The focused tile grows a little, like the console interface. - Root.GotFocus += (_, e) => ScaleTile(e.OriginalSource, 1.06f); - Root.LostFocus += (_, e) => ScaleTile(e.OriginalSource, 1f); + // The focused row / card outlines in white and grows a little, like the console interface. + Root.GotFocus += (_, e) => ScaleFocused(e.OriginalSource, grow: true); + Root.LostFocus += (_, e) => ScaleFocused(e.OriginalSource, grow: false); // Mouse clicks sound like a confirm (the pad has its own sounds in the navigator). Root.AddHandler(UIElement.PointerPressedEvent, new PointerEventHandler(OnPointerPressed), true); @@ -145,20 +146,21 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target) private static bool Animate => Ui.AnimationsEnabled; - // ── Look: acrylic glass and rounded corners ────────────────────────────────────────────── + // ── Look: dim glass over the game ──────────────────────────────────────────────────────── - private void TryGlassAndRoundCorners() + private void TryGlass() { try { SystemBackdrop = new DesktopAcrylicBackdrop(); - var round = 2; // DWMWCP_ROUND - DwmSetWindowAttribute(_hwnd, 33 /* DWMWA_WINDOW_CORNER_PREFERENCE */, ref round, sizeof(int)); + // A full-screen overlay has no rounded corners. + var square = 1; // DWMWCP_DONOTROUND + DwmSetWindowAttribute(_hwnd, 33 /* DWMWA_WINDOW_CORNER_PREFERENCE */, ref square, sizeof(int)); } catch (Exception ex) { - // Plain dark panel: still a working menu. - AppLog.Write($"Menu da sessão: vidro/cantos: {ex.Message}"); + // A plain dark veil: still a working menu. + AppLog.Write($"Menu da sessão: vidro: {ex.Message}"); } } @@ -170,8 +172,8 @@ private void TryGlassAndRoundCorners() private IEnumerable EntranceElements() { yield return HeaderPanel; - foreach (var tile in TilesPanel.Children) yield return tile; - yield return ExitPanel; + yield return SidebarPanel; + yield return WindowsPanel; yield return HintsPanel; } @@ -180,6 +182,7 @@ private void HideForEntrance() try { foreach (var element in EntranceElements()) ElementCompositionPreview.GetElementVisual(element).Opacity = 0f; + foreach (var row in RowsPanel.Children) ElementCompositionPreview.GetElementVisual(row).Opacity = 0f; } catch (Exception ex) { @@ -187,28 +190,28 @@ private void HideForEntrance() } } - /// The header first, then each tile 45 ms after the one before, then the exits and the hints. + /// The header drops in, the side panel slides in from the left with its rows one after another, then the windows and the hints. private void PlayEntrance() { try { + Enter(HeaderPanel, 0f, -16f, 260, 0); + Enter(SidebarPanel, -56f, 0f, 320, 40); var index = 0; - foreach (var element in EntranceElements()) - { - var isHeader = ReferenceEquals(element, HeaderPanel); - Enter(element, isHeader ? 14f : 26f, isHeader ? 240 : 290, isHeader ? 0 : 60 + index * 45); - if (!isHeader) index++; - } + foreach (var row in RowsPanel.Children) Enter(row, 0f, 14f, 260, 120 + index++ * 40); + Enter(WindowsPanel, 0f, 26f, 320, 180); + Enter(HintsPanel, 0f, 10f, 240, 320); } catch (Exception ex) { AppLog.Write($"Menu da sessão: animação: {ex.Message}"); foreach (var element in EntranceElements()) ElementCompositionPreview.GetElementVisual(element).Opacity = 1f; + foreach (var row in RowsPanel.Children) ElementCompositionPreview.GetElementVisual(row).Opacity = 1f; } } - /// Slides an element up from while it fades in. - private static void Enter(UIElement element, float fromY, int milliseconds, int delayMilliseconds) + /// Slides an element in from (, ) while it fades in. + private static void Enter(UIElement element, float fromX, float fromY, int milliseconds, int delayMilliseconds) { ElementCompositionPreview.SetIsTranslationEnabled(element, true); var visual = ElementCompositionPreview.GetElementVisual(element); @@ -216,18 +219,18 @@ private static void Enter(UIElement element, float fromY, int milliseconds, int var ease = compositor.CreateCubicBezierEasingFunction(new Vector2(0.1f, 0.9f), new Vector2(0.2f, 1f)); var delay = TimeSpan.FromMilliseconds(delayMilliseconds); - var slide = compositor.CreateScalarKeyFrameAnimation(); - slide.InsertKeyFrame(0f, fromY); - slide.InsertKeyFrame(1f, 0f, ease); + var slide = compositor.CreateVector3KeyFrameAnimation(); + slide.InsertKeyFrame(0f, new Vector3(fromX, fromY, 0f)); + slide.InsertKeyFrame(1f, Vector3.Zero, ease); slide.Duration = TimeSpan.FromMilliseconds(milliseconds); slide.DelayTime = delay; slide.DelayBehavior = AnimationDelayBehavior.SetInitialValueBeforeDelay; - visual.StartAnimation("Translation.Y", slide); + visual.StartAnimation("Translation", slide); var fade = compositor.CreateScalarKeyFrameAnimation(); fade.InsertKeyFrame(0f, 0f); fade.InsertKeyFrame(1f, 1f, ease); - fade.Duration = TimeSpan.FromMilliseconds(milliseconds - 40); + fade.Duration = TimeSpan.FromMilliseconds(Math.Max(milliseconds - 40, 120)); fade.DelayTime = delay; fade.DelayBehavior = AnimationDelayBehavior.SetInitialValueBeforeDelay; visual.StartAnimation("Opacity", fade); @@ -248,7 +251,7 @@ public void CloseAnimated() var sink = compositor.CreateScalarKeyFrameAnimation(); sink.InsertKeyFrame(0f, 0f); - sink.InsertKeyFrame(1f, 26f, ease); + sink.InsertKeyFrame(1f, 22f, ease); sink.Duration = TimeSpan.FromMilliseconds(170); visual.StartAnimation("Translation.Y", sink); @@ -272,43 +275,46 @@ public void CloseAnimated() _closeTimer.Start(); } - /// A panel over the tiles (the picker, the window switcher): the veil fades in, the card fades and rises in. - private void PlayOverlayEntrance(UIElement overlay, UIElement card) + /// The picker fades and rises in over everything. + private void PlayPickerEntrance() { if (!Animate) return; try { - ElementCompositionPreview.GetElementVisual(overlay).Opacity = 0f; - Enter(overlay, 0f, 200, 0); - ElementCompositionPreview.GetElementVisual(card).Opacity = 0f; - Enter(card, 22f, 240, 30); + ElementCompositionPreview.GetElementVisual(PickerOverlay).Opacity = 0f; + Enter(PickerOverlay, 0f, 0f, 200, 0); + ElementCompositionPreview.GetElementVisual(PickerCard).Opacity = 0f; + Enter(PickerCard, 0f, 22f, 240, 30); } catch (Exception ex) { AppLog.Write($"Menu da sessão: animação: {ex.Message}"); - ElementCompositionPreview.GetElementVisual(overlay).Opacity = 1f; - ElementCompositionPreview.GetElementVisual(card).Opacity = 1f; + ElementCompositionPreview.GetElementVisual(PickerOverlay).Opacity = 1f; + ElementCompositionPreview.GetElementVisual(PickerCard).Opacity = 1f; } } /// - /// Tiles (not the picker rows) grow when focused and shrink back when it leaves. Done on the - /// composition visual: once a tile's visual is in use for the entrance animation, WinUI refuses - /// UIElement.Scale and CenterPoint on it. + /// Window cards grow 6% and side rows 2.5% when focused (Tag "card" / "row") and shrink back when it + /// leaves. Done on the composition visual: once a visual is used for the entrance animation, WinUI + /// refuses UIElement.Scale and CenterPoint on it. /// - private static void ScaleTile(object source, float scale) + private static void ScaleFocused(object source, bool grow) { - if (source is not Button { Tag: "card" } button) return; + if (source is not Button button) return; + var factor = button.Tag switch { "card" => 1.06f, "row" => 1.025f, _ => 0f }; + if (factor == 0f) return; try { + var scale = grow ? factor : 1f; var visual = ElementCompositionPreview.GetElementVisual(button); visual.CenterPoint = new Vector3((float)button.ActualWidth / 2, (float)button.ActualHeight / 2, 0); var compositor = visual.Compositor; - var grow = compositor.CreateVector3KeyFrameAnimation(); - grow.InsertKeyFrame(1f, new Vector3(scale, scale, 1f), + var animation = compositor.CreateVector3KeyFrameAnimation(); + animation.InsertKeyFrame(1f, new Vector3(scale, scale, 1f), compositor.CreateCubicBezierEasingFunction(new Vector2(0.1f, 0.9f), new Vector2(0.2f, 1f))); - grow.Duration = TimeSpan.FromMilliseconds(140); - visual.StartAnimation("Scale", grow); + animation.Duration = TimeSpan.FromMilliseconds(140); + visual.StartAnimation("Scale", animation); } catch (Exception ex) { @@ -333,7 +339,7 @@ private void OnPointerPressed(object sender, PointerRoutedEventArgs e) private bool IsVolumeFocused() => Root.XamlRoot is { } root && ReferenceEquals(FocusManager.GetFocusedElement(root), VolumeRow); - /// A on the volume tile toggles adjust mode; the arrows show while it is on. + /// A on the volume row toggles adjust mode; the arrows show while it is on. private void OnVolumeClick(object sender, RoutedEventArgs e) => SetEditingVolume(!_editingVolume); private void SetEditingVolume(bool on) @@ -342,6 +348,15 @@ private void SetEditingVolume(bool on) VolumeLeft.Visibility = VolumeRight.Visibility = on ? Visibility.Visible : Visibility.Collapsed; } + /// X / Delete on a window card: ask that window to close. False when the focus is not on a window card. + private bool CloseFocusedWindow() + { + if (Root.XamlRoot is not { } root) return false; + if (FocusManager.GetFocusedElement(root) is not FrameworkElement { DataContext: SwitchWindowItem item }) return false; + _ = ViewModel.CloseSwitcherWindowAsync(item); + return true; + } + /// Windows won't hand a background process the foreground; this forces it (PlayStation pads need it). public void BringToFront() { @@ -360,44 +375,13 @@ private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e) case nameof(MainViewModel.IsSessionPickerOpen): DispatcherQueue.TryEnqueue(() => { - if (ViewModel.IsSessionPickerOpen) { PlayOverlayEntrance(PickerOverlay, PickerCard); FocusPickerSelection(); } - else FirstRow.Focus(FocusState.Keyboard); - }); - break; - case nameof(MainViewModel.IsSessionSwitcherOpen): - DispatcherQueue.TryEnqueue(() => - { - if (ViewModel.IsSessionSwitcherOpen) { PlayOverlayEntrance(SwitcherOverlay, SwitcherCard); FocusSwitcherCard(); } + if (ViewModel.IsSessionPickerOpen) { PlayPickerEntrance(); FocusPickerSelection(); } else FirstRow.Focus(FocusState.Keyboard); }); break; } } - /// X / Delete in the switcher: ask the highlighted window to close. - private void CloseFocusedWindow() - { - if (Root.XamlRoot is not { } root) return; - if (FocusManager.GetFocusedElement(root) is FrameworkElement { DataContext: SwitchWindowItem item }) - _ = ViewModel.CloseSwitcherWindowAsync(item); - } - - /// Opens on the second card (the first is the window that was in front), like Alt + Tab. - private void FocusSwitcherCard() - { - var index = AltTabRules.InitialIndex(ViewModel.SwitcherWindows.Count); - bool TryFocus() => - SwitcherList.ContainerFromIndex(index) is { } container - && (FocusManager.FindFirstFocusableElement(container) as Control)?.Focus(FocusState.Keyboard) == true; - if (ViewModel.SwitcherWindows.Count == 0 || TryFocus()) return; - void OnLayout(object? s, object e) - { - SwitcherList.LayoutUpdated -= OnLayout; - TryFocus(); - } - SwitcherList.LayoutUpdated += OnLayout; - } - private void FocusPickerSelection() { var index = Math.Max(ViewModel.SessionPickerOptions.ToList().FindIndex(o => o.IsSelected), 0); diff --git a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs index c2ce5aa..f486f55 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs @@ -79,6 +79,7 @@ public void ToggleSessionMenu() _sessionMenu.Activate(); _sessionMenu.BringToFront(); _ = LoadSessionValuesAsync(); + _ = RefreshSwitcherAsync(); AppLog.Write(IsSessionMenuPreview ? "Menu da sessão: aberto (prévia, fora da sessão)" : "Menu da sessão: aberto"); } @@ -111,15 +112,13 @@ private async Task ShowSessionHintAsync() private void CloseSessionMenu() { IsSessionPickerOpen = false; - IsSessionSwitcherOpen = false; _sessionMenu?.CloseAnimated(); } - /// B: closes the window switcher or the picker first, then the menu. + /// B: closes the picker first, then the menu. public void SessionMenuBack() { - if (IsSessionSwitcherOpen) IsSessionSwitcherOpen = false; - else if (IsSessionPickerOpen) IsSessionPickerOpen = false; + if (IsSessionPickerOpen) IsSessionPickerOpen = false; else CloseSessionMenu(); } @@ -176,7 +175,6 @@ private async Task LoadSessionValuesAsync() SessionAudioText = audioName; VolumePercent = volume ?? -1; SessionFpsText = state.FpsLimit > 0 ? $"{state.FpsLimit} FPS" : LocalizationService.Get("FpsNoLimit"); - SessionWindowsText = LocalizationService.Get("SessionWindowsCount", await Task.Run(() => WindowSwitcher.List().Count)); OnPropertyChanged(nameof(IsFpsMenuAvailable)); } diff --git a/src/ConsoleMode/ViewModels/MainViewModel.Switcher.cs b/src/ConsoleMode/ViewModels/MainViewModel.Switcher.cs index 96e222b..4c754a2 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.Switcher.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.Switcher.cs @@ -21,26 +21,17 @@ public sealed partial class SwitchWindowItem : ObservableObject [ObservableProperty] private ImageSource? _icon; } -// The "Alt + Tab" inside the session menu: a strip of the open windows, picked with the pad. +// The "Alt + Tab" inside the session menu: the open windows, always on screen, picked with the pad. public partial class MainViewModel { public ObservableCollection SwitcherWindows { get; } = []; - [ObservableProperty] private bool _isSessionSwitcherOpen; [ObservableProperty] private string _sessionWindowsText = ""; public bool SwitcherIsEmpty => SwitcherWindows.Count == 0; - [RelayCommand] - private async Task OpenSessionSwitcherAsync() - { - if (IsSessionMenuBusy) return; - await RefreshSwitcherAsync(); - IsSessionSwitcherOpen = true; - } - - /// Lists the windows off the UI thread, then fills the strip and starts loading the icons. - private async Task RefreshSwitcherAsync() + /// Lists the windows off the UI thread, then fills the grid and starts loading the icons. + public async Task RefreshSwitcherAsync() { var windows = await Task.Run(WindowSwitcher.List); SwitcherWindows.Clear(); @@ -75,7 +66,6 @@ private void SwitchToWindow(SwitchWindowItem? item) if (item is null) return; var ok = WindowSwitcher.Activate(item.Window.Handle); AppLog.Write($"Janelas: trocar para \"{item.Title}\" ({item.ProcessName}) => {(ok ? "ok" : "não conseguiu")}"); - IsSessionSwitcherOpen = false; CloseSessionMenu(); } diff --git a/tests/ConsoleMode.Tests/AltTabRulesTests.cs b/tests/ConsoleMode.Tests/AltTabRulesTests.cs index 325c680..1db6c6f 100644 --- a/tests/ConsoleMode.Tests/AltTabRulesTests.cs +++ b/tests/ConsoleMode.Tests/AltTabRulesTests.cs @@ -38,14 +38,6 @@ public void Tool_windows_and_owned_dialogs_are_skipped_unless_they_say_they_are_ Assert.True(AltTabRules.ShouldList(Normal() with { HasOwner = true, IsAppWindow = true })); } - [Theory] - [InlineData(0, 0)] - [InlineData(1, 0)] - [InlineData(2, 1)] - [InlineData(9, 1)] - public void The_switcher_opens_on_the_second_card_like_alt_tab(int count, int expected) => - Assert.Equal(expected, AltTabRules.InitialIndex(count)); - [Theory] [InlineData("chrome.exe", "Chrome")] [InlineData("Discord.EXE", "Discord")] From 6d49354cee64db8555a391d10fd871e5ff421d4a Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Wed, 30 Sep 2026 16:42:26 -0300 Subject: [PATCH 4/5] fix: session menu polish: no frame, conditional glass, selection ring, controller flow Reviewed with the ui-ux-pro-max (WinUI 3 stack) and hallmark (audit) design skills, kept out of the repo (installed under .claude/skills, git-excluded). Look - The light 3 px frame around the full-screen menu was the classic window frame: WinUI's SetBorderAndTitleBar(false, false) leaves WS_DLGFRAME and WS_EX_WINDOWEDGE on, which also shrank the client area (1914x1074 in 1920x1080). WindowChrome.Strip removes them; the toast uses it too. - Glass (DesktopAcrylicBackdrop) only when Windows' transparency effects are on, re-applied live on UISettings.ColorValuesChanged; otherwise a solid dark panel with the same contrast. - One selection marker: a 2 px ring on the edge of the row or card (it sat 5 px out with a gap of background before), no hairline on the rows or the panel (a bordered container holding bordered cards), rows stay still, cards grow 4%. - Colours are named tokens with a high-contrast variant instead of ~30 inline hex values; labels 14 -> 16 px and hints 15 -> 17 px for a couch. Controller - Left from the grid returns to the panel row you came from; up/down stay inside the panel or the grid, so the end of a list is an end (no jump to the other side or to the card beside it: GamepadNavigator.ReadingOrderFallback). - The picker gives the focus back to the row that opened it; closing a window card moves the focus to the next card. Robustness - A menu that fails to build is logged and dropped instead of crashing the app (a bad resource lookup had taken the process down with a stowed exception). Co-Authored-By: Claude Sonnet 5.5 --- CHANGELOG.en-US.md | 2 +- CHANGELOG.md | 2 +- docs/TESTING.md | 6 + src/ConsoleMode/Native/WindowChrome.cs | 46 +++++++ src/ConsoleMode/Services/GamepadNavigator.cs | 8 +- src/ConsoleMode/SessionHintWindow.cs | 1 + src/ConsoleMode/SessionMenuWindow.xaml | 72 +++++++--- src/ConsoleMode/SessionMenuWindow.xaml.cs | 125 +++++++++++++++--- .../ViewModels/MainViewModel.SessionMenu.cs | 12 +- 9 files changed, 235 insertions(+), 39 deletions(-) create mode 100644 src/ConsoleMode/Native/WindowChrome.cs diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index ef38832..4f0e2dd 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -4,7 +4,7 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ## [Unreleased] ### What's new -- Session menu (the controller shortcut, Select + Y in the suggestion) redesigned like Steam's overlay (Shift + Tab): it covers the whole screen, the game dims and blurs behind it, a side panel gathers the options (volume with a gauge, resolution, audio, FPS, HDR) and the **open windows** are always visible in the middle, an in-menu Alt + Tab: the D-pad picks, A brings the window to the front, X closes it and B goes back to the game (switching windows does not end the session). It has a focus that grows, sounds and animations (the panel slides in, the rows follow one after another and closing fades it out). In this alpha it also opens **outside a session**, as a preview with a badge on top, so it can be tried without turning on console mode (in it the FPS limit is hidden and "Back to the PC" becomes "Close menu"). (#120) +- Session menu (the controller shortcut, Select + Y in the suggestion) redesigned like Steam's overlay (Shift + Tab): it covers the whole screen, the game dims and blurs behind it, a side panel gathers the options (volume with a gauge, resolution, audio, FPS, HDR) and the **open windows** are always visible in the middle, an in-menu Alt + Tab: the D-pad picks, A brings the window to the front, X closes it and B goes back to the game (switching windows does not end the session). The frosted glass only shows when Windows' transparency effects are on (otherwise the panel is dark and solid, and it changes live if you flip the setting), and in high contrast it uses the system colours. It has a focus that grows, sounds and animations (the panel slides in, the rows follow one after another and closing fades it out). In this alpha it also opens **outside a session**, as a preview with a badge on top, so it can be tried without turning on console mode (in it the FPS limit is hidden and "Back to the PC" becomes "Close menu"). (#120) - 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) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2e757a7..78e64c9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,7 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ## [Unreleased] ### Novidades -- Menu da sessão (o atalho do controle, Select + Y na sugestão) redesenhado como o overlay do Steam (Shift+Tab): cobre a tela inteira, o jogo escurece e desfoca por trás, um painel lateral reúne as opções (volume com medidor, resolução, áudio, FPS, HDR) e no meio ficam sempre visíveis as **janelas abertas**, um ALT+TAB interno: o direcional escolhe, A traz a janela para a frente, X a fecha e B volta ao jogo (trocar de janela não encerra a sessão). Tem foco que cresce, sons e animações (o painel entra deslizando, as linhas vêm em cascata e ao fechar ele some suavemente). Nesta alpha ele abre também **fora da sessão**, como prévia com um selo no topo, para testar sem ligar o modo console (nela o limite de FPS não aparece e "Voltar ao PC" vira "Fechar menu"). (#120) +- Menu da sessão (o atalho do controle, Select + Y na sugestão) redesenhado como o overlay do Steam (Shift+Tab): cobre a tela inteira, o jogo escurece e desfoca por trás, um painel lateral reúne as opções (volume com medidor, resolução, áudio, FPS, HDR) e no meio ficam sempre visíveis as **janelas abertas**, um ALT+TAB interno: o direcional escolhe, A traz a janela para a frente, X a fecha e B volta ao jogo (trocar de janela não encerra a sessão). O vidro fosco só aparece se os efeitos de transparência do Windows estiverem ligados (senão o painel fica escuro e sólido, e muda ao vivo se você trocar a configuração), e em alto contraste usa as cores do sistema. Tem foco que cresce, sons e animações (o painel entra deslizando, as linhas vêm em cascata e ao fechar ele some suavemente). Nesta alpha ele abre também **fora da sessão**, como prévia com um selo no topo, para testar sem ligar o modo console (nela o limite de FPS não aparece e "Voltar ao PC" vira "Fechar menu"). (#120) - 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) diff --git a/docs/TESTING.md b/docs/TESTING.md index 82cff5c..d020c11 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -124,6 +124,12 @@ Pré-condições: Ajustes → "Entrar ao conectar um controle" **ligado**; app n - [ ] A lista não mostra o próprio menu, a barra de tarefas, a área de trabalho nem apps UWP suspensos; sem janelas mostra "Nenhuma janela aberta". Resultado: ______ - [ ] Em sessão (Big Picture/Playnite aberto): trocar para outra janela **não** restaura a mesa, e voltar ao Big Picture pelo grid funciona; uma janela aberta na tela de jogo aparece na lista. Resultado: ______ - [ ] Monitor com escala de 150% ou 200% (a TV 4K): nada fica cortado nem minúsculo. Resultado: ______ +- [ ] **Sem moldura**: nenhuma borda clara em volta da tela (antes havia um quadro de ~3 px do Windows); o fundo vai até as quatro bordas. Resultado: ______ +- [ ] **Vidro condicional**: com Configurações → Personalização → Cores → "Efeitos de transparência" **ligado**, o jogo aparece desfocado e escurecido atrás; **desligado**, o painel fica escuro e sólido, com o mesmo contraste; trocar a configuração com o menu aberto atualiza na hora. Resultado: ______ +- [ ] **Marcador de seleção**: um contorno branco de 2 px encostado na borda da linha ou do card (sem vão de fundo entre os dois e sem ser cortado na ponta da lista); as linhas do painel não mudam de tamanho e os cards de janela crescem ~4%. O modo de ajuste do volume mostra ◀ ▶ e o contorno acompanha. Resultado: ______ +- [ ] **Controle**: esquerda, vinda do grid, volta à linha do painel de onde você saiu; cima/baixo param nas pontas do painel e do grid (não pulam para o outro lado nem para o card ao lado); o seletor (resolução, áudio, FPS) devolve o foco à linha que o abriu; ao fechar uma janela (X) o foco vai para o card seguinte, ou para "Voltar ao jogo" se não sobrar nenhum. Resultado: ______ +- [ ] **Volume**: A liga o modo de ajuste, ◀/▶ mudam de 5 em 5, cima/baixo não fazem nada nele, X (□) muda o mudo, B (ou sair da linha) sai do ajuste sem fechar o menu. Resultado: ______ +- [ ] **Legibilidade a 3 m**: rótulos (16 px), dicas (17 px) e valores (19 px) legíveis no sofá, a 1080p e a 4K com escala; em modo de alto contraste do Windows os textos e fundos usam as cores do sistema. Resultado: ______ 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. diff --git a/src/ConsoleMode/Native/WindowChrome.cs b/src/ConsoleMode/Native/WindowChrome.cs new file mode 100644 index 0000000..c4ca8b4 --- /dev/null +++ b/src/ConsoleMode/Native/WindowChrome.cs @@ -0,0 +1,46 @@ +using System.Runtime.InteropServices; + +namespace ConsoleMode.Native; + +/// +/// Strips the classic Windows frame from a borderless overlay window. WinUI's +/// SetBorderAndTitleBar(false, false) removes the title bar and the resize border but leaves +/// WS_DLGFRAME and WS_EX_WINDOWEDGE on, which Windows draws as a light 3 px frame and +/// which also makes the client area smaller than the window (1914x1074 inside 1920x1080). +/// +public static class WindowChrome +{ + private const int GwlStyle = -16; + private const int GwlExStyle = -20; + private const long WsBorder = 0x00800000; + private const long WsDlgFrame = 0x00400000; + private const long WsThickFrame = 0x00040000; + private const long WsSysMenu = 0x00080000; + private const long WsExWindowEdge = 0x00000100; + private const long WsExClientEdge = 0x00000200; + private const long WsExDlgModalFrame = 0x00000001; + private const long WsExStaticEdge = 0x00020000; + private const uint SwpNoSize = 0x0001, SwpNoMove = 0x0002, SwpNoZOrder = 0x0004, SwpNoActivate = 0x0010, SwpFrameChanged = 0x0020; + + /// Removes every frame style; call before sizing the window so the client area is the whole window. + public static void Strip(nint hwnd) + { + var style = GetWindowLongPtr(hwnd, GwlStyle).ToInt64(); + var exStyle = GetWindowLongPtr(hwnd, GwlExStyle).ToInt64(); + style &= ~(WsBorder | WsDlgFrame | WsThickFrame | WsSysMenu); + exStyle &= ~(WsExWindowEdge | WsExClientEdge | WsExDlgModalFrame | WsExStaticEdge); + SetWindowLongPtr(hwnd, GwlStyle, (nint)style); + SetWindowLongPtr(hwnd, GwlExStyle, (nint)exStyle); + // Tell Windows the frame changed, or the old one stays until the next resize. + SetWindowPos(hwnd, 0, 0, 0, 0, 0, SwpNoSize | SwpNoMove | SwpNoZOrder | SwpNoActivate | SwpFrameChanged); + } + + [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] + private static extern nint GetWindowLongPtr(nint hwnd, int index); + + [DllImport("user32.dll", EntryPoint = "SetWindowLongPtrW")] + private static extern nint SetWindowLongPtr(nint hwnd, int index, nint value); + + [DllImport("user32.dll")] + private static extern bool SetWindowPos(nint hwnd, nint insertAfter, int x, int y, int cx, int cy, uint flags); +} diff --git a/src/ConsoleMode/Services/GamepadNavigator.cs b/src/ConsoleMode/Services/GamepadNavigator.cs index 977d3df..e4666ce 100644 --- a/src/ConsoleMode/Services/GamepadNavigator.cs +++ b/src/ConsoleMode/Services/GamepadNavigator.cs @@ -46,6 +46,12 @@ public sealed class GamepadNavigator : IDisposable /// public bool NearestOnSides { get; set; } + /// + /// Up/down fall back to reading order (top to bottom, then left to right) when nothing else is found: right + /// for a scrolled list, wrong for a grid, where the next item in reading order is the one beside it. + /// + public bool ReadingOrderFallback { get; set; } = true; + /// Play the interface sounds for moves, confirms and backs (the console interface). public bool Sounds { get; set; } @@ -201,7 +207,7 @@ private void Move(FocusNavigationDirection direction) // take the nearest one in that direction instead of doing nothing. ?? FindNearest(direction) // Scrolled lists: controls outside the viewport aren't in a spatial search at all. - ?? FindInReadingOrder(control, direction); + ?? (ReadingOrderFallback ? FindInReadingOrder(control, direction) : null); if (target is null) return; target.Focus(FocusState.Keyboard); Play(UiSound.Move); diff --git a/src/ConsoleMode/SessionHintWindow.cs b/src/ConsoleMode/SessionHintWindow.cs index 26c892a..6a0991b 100644 --- a/src/ConsoleMode/SessionHintWindow.cs +++ b/src/ConsoleMode/SessionHintWindow.cs @@ -62,6 +62,7 @@ public SessionHintWindow(string title, string body, ScreenRect? target, TimeSpan presenter.IsMinimizable = false; presenter.SetBorderAndTitleBar(false, false); } + WindowChrome.Strip(hwnd); WindowPlacement.TopRightOn(appWindow, target, WidthDip, HeightDip, MarginDip); appWindow.Show(activateWindow: false); diff --git a/src/ConsoleMode/SessionMenuWindow.xaml b/src/ConsoleMode/SessionMenuWindow.xaml index c436d5e..7c296a3 100644 --- a/src/ConsoleMode/SessionMenuWindow.xaml +++ b/src/ConsoleMode/SessionMenuWindow.xaml @@ -10,9 +10,30 @@ - + + + + + + + + + + + + + + + + + + + + + + @@ -55,6 +86,7 @@ + @@ -77,10 +109,10 @@ - - - + + @@ -94,7 +126,7 @@ - + - +