diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md
index 568890d..2431c4c 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 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)
@@ -11,6 +12,7 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor
- Settings → "Receive test versions (alpha and beta)": anyone can join the tests of upcoming versions from the app. Off by default; turning it on shows a warning that test versions can have bugs.
### Fixes
+- Session menu: if Windows cannot activate the selected window, the menu stays open so you can try again. (#120)
- 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)
diff --git a/CHANGELOG.md b/CHANGELOG.md
index b3402f4..639dcc1 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 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)
@@ -11,6 +12,7 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH
- Ajustes → "Receber versões de teste (alpha e beta)": qualquer pessoa pode entrar nos testes das próximas versões pelo próprio app. Desligado por padrão; ao ligar, o app avisa que a versão pode ter bugs.
### Correções
+- Menu da sessão: se o Windows não conseguir ativar a janela escolhida, o menu continua aberto para tentar de novo. (#120)
- 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)
diff --git a/docs/TESTING.md b/docs/TESTING.md
index ea90627..b24fbc6 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -116,6 +116,26 @@ Pré-condições: Ajustes → "Entrar ao conectar um controle" **ligado**; app n
## 1c. Menu da sessão (atalho sugerido: Select + Y)
+### 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: ______
+- [ ] **Falha ao ativar uma janela**: abra o menu com o Bloco de Notas aberto, feche o Bloco de Notas por outro meio depois de a lista carregar (por exemplo, `taskkill /IM notepad.exe /F`) e escolha o card obsoleto com A. O menu continua aberto, o foco permanece no card e o log mostra `Janelas: trocar ... => não conseguiu`; uma falha do Windows ao trazer uma janela válida para frente também deve manter o menu aberto. 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: ______
+- [ ] **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.
- [ ] 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/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/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/Native/WindowSwitcher.cs b/src/ConsoleMode/Native/WindowSwitcher.cs
new file mode 100644
index 0000000..c27eea3
--- /dev/null
+++ b/src/ConsoleMode/Native/WindowSwitcher.cs
@@ -0,0 +1,128 @@
+using System.Runtime.InteropServices;
+using System.Text;
+using ConsoleMode.Services;
+
+namespace ConsoleMode.Native;
+
+/// One entry of the in-menu window switcher.
+public sealed record SwitchWindow(nint Handle, string Title, string ProcessName, string? ExePath);
+
+///
+/// The window switcher behind the session menu: lists the windows Alt + Tab would, in front-to-back
+/// order, brings one to the front and asks one to close. Only windows are touched: the session
+/// engine decides a session is over by whether Big Picture / Playnite is still visible, not by
+/// focus, so switching away from it never ends the session.
+///
+public static class WindowSwitcher
+{
+ private const int GwlExStyle = -20;
+ private const long WsExToolWindow = 0x00000080;
+ private const long WsExAppWindow = 0x00040000;
+ private const uint GwOwner = 4;
+ private const int DwmaCloaked = 14;
+ private const int SwRestore = 9;
+ private const uint WmClose = 0x0010;
+
+ /// Windows Alt + Tab would list, front to back. Never throws: a window that vanishes mid-way is skipped.
+ public static List List()
+ {
+ var result = new List();
+ var own = (uint)Environment.ProcessId;
+ EnumWindows((hwnd, _) =>
+ {
+ try
+ {
+ var visible = IsWindowVisible(hwnd);
+ if (!visible) return true;
+ GetWindowThreadProcessId(hwnd, out var pid);
+ var title = new StringBuilder(256);
+ GetWindowText(hwnd, title, title.Capacity);
+ var className = new StringBuilder(128);
+ GetClassName(hwnd, className, className.Capacity);
+ var style = GetWindowLongPtr(hwnd, GwlExStyle).ToInt64();
+ DwmGetWindowAttribute(hwnd, DwmaCloaked, out var cloaked, sizeof(int));
+
+ var facts = new WindowFacts(title.ToString(), className.ToString(), visible, cloaked != 0,
+ GetWindow(hwnd, GwOwner) != 0, (style & WsExToolWindow) != 0, (style & WsExAppWindow) != 0, pid == own);
+ if (!AltTabRules.ShouldList(facts)) return true;
+
+ var exe = ExePathOf(pid);
+ var name = exe is null ? "" : Path.GetFileName(exe);
+ result.Add(new SwitchWindow(hwnd, facts.Title, AltTabRules.ProcessDisplayName(name), exe));
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Janelas: {ex.Message}");
+ }
+ return true;
+ }, 0);
+ return result;
+ }
+
+ /// Brings a window to the front (restoring it if minimized), without leaving it topmost.
+ public static bool Activate(nint hwnd)
+ {
+ if (hwnd == 0 || !IsWindow(hwnd)) return false;
+ if (IsIconic(hwnd)) ShowWindow(hwnd, SwRestore);
+ var ourThread = GetCurrentThreadId();
+ var current = GetForegroundWindow();
+ var theirThread = current == 0 ? 0 : GetWindowThreadProcessId(current, out _);
+ var attached = theirThread != 0 && theirThread != ourThread && AttachThreadInput(ourThread, theirThread, true);
+ try
+ {
+ BringWindowToTop(hwnd);
+ return SetForegroundWindow(hwnd);
+ }
+ finally
+ {
+ if (attached) AttachThreadInput(ourThread, theirThread, false);
+ }
+ }
+
+ /// Asks the window to close (the same as its X button: it may still ask to save).
+ public static void RequestClose(nint hwnd)
+ {
+ if (hwnd != 0 && IsWindow(hwnd)) PostMessage(hwnd, WmClose, 0, 0);
+ }
+
+ public static bool Exists(nint hwnd) => hwnd != 0 && IsWindow(hwnd);
+
+ private static string? ExePathOf(uint pid)
+ {
+ var process = OpenProcess(0x1000 /* PROCESS_QUERY_LIMITED_INFORMATION */, false, pid);
+ if (process == 0) return null;
+ try
+ {
+ var path = new StringBuilder(1024);
+ var size = path.Capacity;
+ return QueryFullProcessImageName(process, 0, path, ref size) ? path.ToString() : null;
+ }
+ finally
+ {
+ CloseHandle(process);
+ }
+ }
+
+ private delegate bool EnumProc(nint hwnd, nint lParam);
+
+ [DllImport("user32.dll")] private static extern bool EnumWindows(EnumProc proc, nint lParam);
+ [DllImport("user32.dll")] private static extern bool IsWindowVisible(nint hwnd);
+ [DllImport("user32.dll")] private static extern bool IsWindow(nint hwnd);
+ [DllImport("user32.dll")] private static extern bool IsIconic(nint hwnd);
+ [DllImport("user32.dll")] private static extern bool ShowWindow(nint hwnd, int command);
+ [DllImport("user32.dll")] private static extern bool BringWindowToTop(nint hwnd);
+ [DllImport("user32.dll")] private static extern bool SetForegroundWindow(nint hwnd);
+ [DllImport("user32.dll")] private static extern nint GetForegroundWindow();
+ [DllImport("user32.dll")] private static extern nint GetWindow(nint hwnd, uint command);
+ [DllImport("user32.dll", EntryPoint = "GetWindowLongPtrW")] private static extern nint GetWindowLongPtr(nint hwnd, int index);
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetWindowText(nint hwnd, StringBuilder text, int max);
+ [DllImport("user32.dll", CharSet = CharSet.Unicode)] private static extern int GetClassName(nint hwnd, StringBuilder text, int max);
+ [DllImport("user32.dll")] private static extern uint GetWindowThreadProcessId(nint hwnd, out uint processId);
+ [DllImport("user32.dll")] private static extern bool AttachThreadInput(uint attach, uint to, bool on);
+ [DllImport("user32.dll")] private static extern bool PostMessage(nint hwnd, uint message, nint wParam, nint lParam);
+ [DllImport("kernel32.dll")] private static extern uint GetCurrentThreadId();
+ [DllImport("kernel32.dll")] private static extern nint OpenProcess(uint access, bool inherit, uint pid);
+ [DllImport("kernel32.dll")] private static extern bool CloseHandle(nint handle);
+ [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] private static extern bool QueryFullProcessImageName(nint process, uint flags, StringBuilder name, ref int size);
+ [DllImport("dwmapi.dll")] private static extern int DwmGetWindowAttribute(nint hwnd, int attribute, out int value, int size);
+}
diff --git a/src/ConsoleMode/Resources/Strings.en-US.json b/src/ConsoleMode/Resources/Strings.en-US.json
index 370dceb..398db33 100644
--- a/src/ConsoleMode/Resources/Strings.en-US.json
+++ b/src/ConsoleMode/Resources/Strings.en-US.json
@@ -86,6 +86,13 @@
"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",
+ "SessionWindowsCard": "Windows",
+ "SessionWindowsCount": "{0} open",
+ "SwitcherEmpty": "No open windows",
+ "SwitcherOpen": "Open",
+ "SwitcherCloseWindow": "Close window",
+ "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 e47b048..adc5d8f 100644
--- a/src/ConsoleMode/Resources/Strings.es-ES.json
+++ b/src/ConsoleMode/Resources/Strings.es-ES.json
@@ -86,6 +86,13 @@
"HintChange": "Cambiar",
"ConsoleSettingsHint": "Pulsa A en una fila para cambiar su valor. Todo se guarda al momento.",
"BackToPc": "Volver al PC",
+ "CloseMenu": "Cerrar menú",
+ "SessionWindowsCard": "Ventanas",
+ "SessionWindowsCount": "{0} abiertas",
+ "SwitcherEmpty": "No hay ventanas abiertas",
+ "SwitcherOpen": "Abrir",
+ "SwitcherCloseWindow": "Cerrar ventana",
+ "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 d615576..e5811be 100644
--- a/src/ConsoleMode/Resources/Strings.pt-BR.json
+++ b/src/ConsoleMode/Resources/Strings.pt-BR.json
@@ -86,6 +86,13 @@
"HintChange": "Alterar",
"ConsoleSettingsHint": "Aperte A numa linha para trocar o valor. Tudo é salvo na hora.",
"BackToPc": "Voltar ao PC",
+ "CloseMenu": "Fechar menu",
+ "SessionWindowsCard": "Janelas",
+ "SessionWindowsCount": "{0} abertas",
+ "SwitcherEmpty": "Nenhuma janela aberta",
+ "SwitcherOpen": "Abrir",
+ "SwitcherCloseWindow": "Fechar janela",
+ "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/AltTabRules.cs b/src/ConsoleMode/Services/AltTabRules.cs
new file mode 100644
index 0000000..94ff2ea
--- /dev/null
+++ b/src/ConsoleMode/Services/AltTabRules.cs
@@ -0,0 +1,37 @@
+namespace ConsoleMode.Services;
+
+/// What the window switcher needs to know about a top-level window to decide if it is listed.
+public readonly record struct WindowFacts(
+ string Title, string ClassName, bool Visible, bool Cloaked, bool HasOwner,
+ bool IsToolWindow, bool IsAppWindow, bool IsOwnProcess);
+
+///
+/// Pure rules of the in-menu window switcher (the "Alt + Tab" of the session menu), the same ones
+/// Windows applies to its own: visible, not cloaked (a suspended UWP app), titled, and neither a
+/// tool window nor an owned dialog unless it says it is an app window. Kept apart so it is tested.
+///
+public static class AltTabRules
+{
+ private static readonly HashSet ShellClasses = new(StringComparer.Ordinal)
+ {
+ "Progman", "WorkerW", "Shell_TrayWnd", "Shell_SecondaryTrayWnd", "Windows.UI.Core.CoreWindow"
+ };
+
+ public static bool ShouldList(WindowFacts window)
+ {
+ if (!window.Visible || window.Cloaked || window.IsOwnProcess) return false;
+ if (string.IsNullOrWhiteSpace(window.Title)) return false;
+ if (ShellClasses.Contains(window.ClassName)) return false;
+ if (window.IsAppWindow) return true;
+ return !window.IsToolWindow && !window.HasOwner;
+ }
+
+ /// "chrome.exe" → "Chrome"; empty when there is no name.
+ public static string ProcessDisplayName(string? processName)
+ {
+ if (string.IsNullOrWhiteSpace(processName)) return "";
+ var name = processName.Trim();
+ if (name.EndsWith(".exe", StringComparison.OrdinalIgnoreCase)) name = name[..^4];
+ return name.Length == 0 ? "" : char.ToUpperInvariant(name[0]) + name[1..];
+ }
+}
diff --git a/src/ConsoleMode/Services/GamepadNavigator.cs b/src/ConsoleMode/Services/GamepadNavigator.cs
index 7517cd6..e4666ce 100644
--- a/src/ConsoleMode/Services/GamepadNavigator.cs
+++ b/src/ConsoleMode/Services/GamepadNavigator.cs
@@ -40,6 +40,18 @@ 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; }
+
+ ///
+ /// 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; }
@@ -124,8 +136,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(),
@@ -194,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/Services/LocalizationService.cs b/src/ConsoleMode/Services/LocalizationService.cs
index 238cf60..4733c60 100644
--- a/src/ConsoleMode/Services/LocalizationService.cs
+++ b/src/ConsoleMode/Services/LocalizationService.cs
@@ -187,6 +187,12 @@ 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 SessionWindowsCard => LocalizationService.Get(nameof(SessionWindowsCard));
+ public string SwitcherEmpty => LocalizationService.Get(nameof(SwitcherEmpty));
+ public string SwitcherOpen => LocalizationService.Get(nameof(SwitcherOpen));
+ public string SwitcherCloseWindow => LocalizationService.Get(nameof(SwitcherCloseWindow));
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/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 032615c..7c296a3 100644
--- a/src/ConsoleMode/SessionMenuWindow.xaml
+++ b/src/ConsoleMode/SessionMenuWindow.xaml
@@ -8,10 +8,32 @@
xmlns:controls="using:ConsoleMode.Controls"
Title="Console Mode">
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
@@ -56,125 +94,214 @@
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
-
-
-
-
-
-
+
+
+
+
+
+
-
+
+
+
+
-
-
-
+
+
-
+
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
-
+
-
+
+
+
+
+
-
+
-
-
+
+
-
-
+
+
@@ -194,7 +321,7 @@
-
+
diff --git a/src/ConsoleMode/SessionMenuWindow.xaml.cs b/src/ConsoleMode/SessionMenuWindow.xaml.cs
index 2bf6c80..85921de 100644
--- a/src/ConsoleMode/SessionMenuWindow.xaml.cs
+++ b/src/ConsoleMode/SessionMenuWindow.xaml.cs
@@ -1,29 +1,43 @@
using System.ComponentModel;
+using System.Numerics;
+using System.Runtime.InteropServices;
using ConsoleMode.Models;
using ConsoleMode.Native;
using ConsoleMode.Services;
using ConsoleMode.ViewModels;
+using Microsoft.UI.Composition;
using Microsoft.UI.Windowing;
using Microsoft.UI.Xaml;
using Microsoft.UI.Xaml.Controls;
+using Microsoft.UI.Xaml.Controls.Primitives;
+using Microsoft.UI.Xaml.Hosting;
using Microsoft.UI.Xaml.Input;
+using Microsoft.UI.Xaml.Media;
+using Windows.UI.ViewManagement;
using WinRT.Interop;
namespace ConsoleMode;
///
-/// The Select + Y menu over the game (see MainViewModel.SessionMenu). Centered on the game
-/// screen, always on top, driven by the controller through GamepadNavigator or by keyboard.
-/// 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 = 1100;
- private const double HeightDip = 620;
+ private static readonly UISettings Ui = new();
private readonly nint _hwnd;
private readonly GamepadNavigator _navigator;
private bool _editingVolume;
+ private bool _closing;
+ private Control? _pickerOpener;
+ private Control? _lastSidebarFocus;
+ private FocusNavigationDirection _lastDirection;
+ private Microsoft.UI.Dispatching.DispatcherQueueTimer? _closeTimer;
public MainViewModel ViewModel { get; }
@@ -42,22 +56,50 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target)
presenter.IsMinimizable = false;
presenter.SetBorderAndTitleBar(false, false);
}
- WindowPlacement.CenterOn(appWindow, target, WidthDip, HeightDip);
+ // Before sizing: the classic frame is what shows as a light border and eats 6 px of the client area.
+ WindowChrome.Strip(_hwnd);
+ WindowPlacement.FillScreen(appWindow, target);
+ TryRemoveRoundingAndOutline();
+ ApplyMaterial();
+
+ // 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
// with the menu on top, and then the menu never heard the controller.
_navigator = new GamepadNavigator(DispatcherQueue, Root, readSonyHid: true)
{
- // Adjust mode on the volume tile: Left/Right change it, Up/Down are swallowed.
+ Sounds = true,
+ // The side panel and the windows grid sit next to each other, never on the same row.
+ NearestOnSides = true,
+ // The end of a column is an end: the next item in reading order would be the one beside it in the grid.
+ ReadingOrderFallback = false,
+ // Focus may only land inside the picker while it is open (it covers everything else); up and
+ // down stay inside the panel or the grid they start in, so the end of a list is an end.
+ SearchRoot = () => ViewModel.IsSessionPickerOpen ? PickerCard
+ : _lastDirection is FocusNavigationDirection.Up or FocusNavigationDirection.Down ? RegionOfFocus() : Root,
+ // Adjust mode on the volume row: Left/Right change it, Up/Down are swallowed.
BeforeMove = direction =>
{
+ _lastDirection = direction;
+ // Left from the first column of windows goes back to the row of the panel you came from.
+ if (!_editingVolume && direction == FocusNavigationDirection.Left && !ViewModel.IsSessionPickerOpen && IsOnLeftmostCard())
+ {
+ (_lastSidebarFocus ?? FirstRow).Focus(FocusState.Keyboard);
+ UiSounds.Play(UiSound.Move);
+ return true;
+ }
if (!_editingVolume) return false;
- if (direction == FocusNavigationDirection.Left) ViewModel.ChangeVolume(-1);
- else if (direction == FocusNavigationDirection.Right) ViewModel.ChangeVolume(1);
+ if (direction == FocusNavigationDirection.Left) { ViewModel.ChangeVolume(-1); UiSounds.Play(UiSound.Move); }
+ else if (direction == FocusNavigationDirection.Right) { ViewModel.ChangeVolume(1); UiSounds.Play(UiSound.Move); }
return true;
}
};
- _navigator.OptionRequested += () => { if (IsVolumeFocused()) ViewModel.ToggleMuteCommand.Execute(null); };
+ // X / Square: close the highlighted window, or mute on the volume row.
+ _navigator.OptionRequested += () =>
+ {
+ if (!CloseFocusedWindow() && IsVolumeFocused()) ViewModel.ToggleMuteCommand.Execute(null);
+ };
_navigator.BackRequested += () =>
{
if (_editingVolume) SetEditingVolume(false);
@@ -69,7 +111,7 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target)
// before they ever bubble up to Root.
Root.PreviewKeyDown += (_, e) =>
{
- // Arrows go through the same path as the D-pad.
+ // 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,
@@ -81,19 +123,36 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target)
if (direction != FocusNavigationDirection.None)
{
e.Handled = true;
- if (_navigator.BeforeMove?.Invoke(direction) != true)
- FocusManager.TryMoveFocus(direction, new FindNextElementOptions { SearchRoot = Root });
+ _navigator.Navigate(direction);
return;
}
- if (e.Key is not (Windows.System.VirtualKey.Escape or Windows.System.VirtualKey.GamepadB)) 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 row / card outlines in white and grows a little, like the console interface.
+ Root.GotFocus += (_, e) =>
+ {
+ ScaleFocused(e.OriginalSource, grow: true);
+ if (e.OriginalSource is Control focused && IsInside(SidebarPanel, focused)) _lastSidebarFocus = focused;
+ };
+ 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);
+
ViewModel.PropertyChanged += OnViewModelChanged;
+ Ui.ColorValuesChanged += OnColorValuesChanged;
VolumeRow.LostFocus += (_, _) => SetEditingVolume(false);
// BringToFront runs before the tree exists; the real first focus happens here.
- Root.Loaded += (_, _) => FirstRow.Focus(FocusState.Keyboard);
+ Root.Loaded += (_, _) =>
+ {
+ FirstRow.Focus(FocusState.Keyboard);
+ if (Animate) PlayEntrance();
+ };
Activated += (_, args) =>
{
// XamlRoot is null on the very first activation; GetFocusedElement(null) throws.
@@ -104,14 +163,230 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target)
Closed += (_, _) =>
{
ViewModel.PropertyChanged -= OnViewModelChanged;
+ Ui.ColorValuesChanged -= OnColorValuesChanged;
_navigator.Dispose();
};
}
+ private static bool Animate => Ui.AnimationsEnabled;
+
+ // ── Look: dim glass over the game, if the user has Windows' transparency effects on ───────
+
+ ///
+ /// Glass when Settings → Personalization → Colors → "Transparency effects" is on (the game dims and
+ /// blurs behind the menu); when it is off, or the battery saver turns it off, a solid dark veil with
+ /// the same contrast. Re-applied live when the user flips the setting.
+ ///
+ private void ApplyMaterial()
+ {
+ var glass = Ui.AdvancedEffectsEnabled;
+ try
+ {
+ SystemBackdrop = glass ? new DesktopAcrylicBackdrop() : null;
+ }
+ catch (Exception ex)
+ {
+ glass = false;
+ AppLog.Write($"Menu da sessão: vidro: {ex.Message}");
+ }
+ Root.Background = new SolidColorBrush(glass ? Windows.UI.Color.FromArgb(0x99, 0x07, 0x0A, 0x0E) : Windows.UI.Color.FromArgb(0xF7, 0x07, 0x0A, 0x0E));
+ SidebarPanel.Background = new SolidColorBrush(glass ? Windows.UI.Color.FromArgb(0xB3, 0x11, 0x18, 0x20) : Windows.UI.Color.FromArgb(0xFF, 0x13, 0x1A, 0x22));
+ }
+
+ private void OnColorValuesChanged(UISettings sender, object args) => DispatcherQueue.TryEnqueue(ApplyMaterial);
+
+ private void TryRemoveRoundingAndOutline()
+ {
+ try
+ {
+ // A full-screen overlay has no rounded corners and no 1 px Windows outline.
+ var square = 1; // DWMWCP_DONOTROUND
+ DwmSetWindowAttribute(_hwnd, 33 /* DWMWA_WINDOW_CORNER_PREFERENCE */, ref square, sizeof(int));
+ var none = unchecked((int)0xFFFFFFFE); // DWMWA_COLOR_NONE
+ DwmSetWindowAttribute(_hwnd, 34 /* DWMWA_BORDER_COLOR */, ref none, sizeof(int));
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Menu da sessão: cantos: {ex.Message}");
+ }
+ }
+
+ [DllImport("dwmapi.dll")]
+ private static extern int DwmSetWindowAttribute(nint hwnd, int attribute, ref int value, int size);
+
+ // ── Animations ───────────────────────────────────────────────────────────────────────────
+
+ private IEnumerable EntranceElements()
+ {
+ yield return HeaderPanel;
+ yield return SidebarPanel;
+ yield return WindowsPanel;
+ yield return HintsPanel;
+ }
+
+ 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)
+ {
+ AppLog.Write($"Menu da sessão: animação: {ex.Message}");
+ }
+ }
+
+ /// 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 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 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);
+ var compositor = visual.Compositor;
+ var ease = compositor.CreateCubicBezierEasingFunction(new Vector2(0.1f, 0.9f), new Vector2(0.2f, 1f));
+ var delay = TimeSpan.FromMilliseconds(delayMilliseconds);
+
+ 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", slide);
+
+ var fade = compositor.CreateScalarKeyFrameAnimation();
+ fade.InsertKeyFrame(0f, 0f);
+ fade.InsertKeyFrame(1f, 1f, ease);
+ fade.Duration = TimeSpan.FromMilliseconds(Math.Max(milliseconds - 40, 120));
+ fade.DelayTime = delay;
+ fade.DelayBehavior = AnimationDelayBehavior.SetInitialValueBeforeDelay;
+ visual.StartAnimation("Opacity", fade);
+ }
+
+ /// Fades the menu out and sinks it a little, then closes the window.
+ public void CloseAnimated()
+ {
+ if (_closing) return;
+ _closing = true;
+ if (!Animate) { Close(); return; }
+ try
+ {
+ ElementCompositionPreview.SetIsTranslationEnabled(Root, true);
+ var visual = ElementCompositionPreview.GetElementVisual(Root);
+ var compositor = visual.Compositor;
+ var ease = compositor.CreateCubicBezierEasingFunction(new Vector2(0.4f, 0f), new Vector2(1f, 1f));
+
+ var sink = compositor.CreateScalarKeyFrameAnimation();
+ sink.InsertKeyFrame(0f, 0f);
+ sink.InsertKeyFrame(1f, 22f, ease);
+ sink.Duration = TimeSpan.FromMilliseconds(170);
+ visual.StartAnimation("Translation.Y", sink);
+
+ var fade = compositor.CreateScalarKeyFrameAnimation();
+ fade.InsertKeyFrame(0f, 1f);
+ fade.InsertKeyFrame(1f, 0f, ease);
+ fade.Duration = TimeSpan.FromMilliseconds(170);
+ visual.StartAnimation("Opacity", fade);
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Menu da sessão: animação: {ex.Message}");
+ Close();
+ return;
+ }
+ // A timer, not the animation's completion: the window must go away even if the composition stalls.
+ _closeTimer = DispatcherQueue.CreateTimer();
+ _closeTimer.Interval = TimeSpan.FromMilliseconds(190);
+ _closeTimer.IsRepeating = false;
+ _closeTimer.Tick += (_, _) => Close();
+ _closeTimer.Start();
+ }
+
+ /// The picker fades and rises in over everything.
+ private void PlayPickerEntrance()
+ {
+ if (!Animate) return;
+ try
+ {
+ 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(PickerOverlay).Opacity = 1f;
+ ElementCompositionPreview.GetElementVisual(PickerCard).Opacity = 1f;
+ }
+ }
+
+ ///
+ /// Window cards grow 4% when focused (Tag "card") and shrink back when it leaves; the side rows stay
+ /// still and show the selection with the ring alone. 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 ScaleFocused(object source, bool grow)
+ {
+ if (source is not Button button) return;
+ var factor = button.Tag switch { "card" => 1.04f, _ => 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 animation = compositor.CreateVector3KeyFrameAnimation();
+ animation.InsertKeyFrame(1f, new Vector3(scale, scale, 1f),
+ compositor.CreateCubicBezierEasingFunction(new Vector2(0.1f, 0.9f), new Vector2(0.2f, 1f)));
+ animation.Duration = TimeSpan.FromMilliseconds(140);
+ visual.StartAnimation("Scale", animation);
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Menu da sessão: foco: {ex.Message}");
+ }
+ }
+
+ private void OnPointerPressed(object sender, PointerRoutedEventArgs e)
+ {
+ for (var node = e.OriginalSource as DependencyObject; node is not null; node = VisualTreeHelper.GetParent(node))
+ {
+ if (node is ButtonBase { IsEnabled: true })
+ {
+ UiSounds.Play(UiSound.Confirm);
+ return;
+ }
+ }
+ }
+
+ // ── Volume adjust mode ───────────────────────────────────────────────────────────────────
+
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)
@@ -120,6 +395,60 @@ private void SetEditingVolume(bool on)
VolumeLeft.Visibility = VolumeRight.Visibility = on ? Visibility.Visible : Visibility.Collapsed;
}
+ private static bool IsInside(DependencyObject ancestor, DependencyObject? node)
+ {
+ for (; node is not null; node = VisualTreeHelper.GetParent(node))
+ if (ReferenceEquals(node, ancestor)) return true;
+ return false;
+ }
+
+ private DependencyObject RegionOfFocus()
+ {
+ var focused = Root.XamlRoot is { } root ? FocusManager.GetFocusedElement(root) as DependencyObject : null;
+ if (IsInside(WindowsPanel, focused)) return WindowsPanel;
+ if (IsInside(SidebarPanel, focused)) return SidebarPanel;
+ return Root;
+ }
+
+ /// True when the focus is on a window card with no card to its left (the first column of the grid).
+ private bool IsOnLeftmostCard()
+ {
+ if (Root.XamlRoot is not { } root || FocusManager.GetFocusedElement(root) is not FrameworkElement { DataContext: SwitchWindowItem } card) return false;
+ double X(FrameworkElement e) => e.TransformToVisual(SwitcherList).TransformPoint(new Windows.Foundation.Point(0, 0)).X;
+ var cardX = X(card);
+ for (var i = 0; i < ViewModel.SwitcherWindows.Count; i++)
+ {
+ if (SwitcherList.ContainerFromIndex(i) is { } container
+ && FocusManager.FindFirstFocusableElement(container) is FrameworkElement other && X(other) < cardX - 1)
+ return false;
+ }
+ return true;
+ }
+
+ /// 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;
+ var index = ViewModel.SwitcherWindows.IndexOf(item);
+ _ = CloseAndRefocusAsync(item, index);
+ return true;
+ }
+
+ /// The card goes away once its window closes: the focus moves to the next one (or the previous, or the first row).
+ private async Task CloseAndRefocusAsync(SwitchWindowItem item, int index)
+ {
+ await ViewModel.CloseSwitcherWindowAsync(item);
+ if (ViewModel.SwitcherWindows.Contains(item)) return; // it asked to save: the card stays, so does the focus
+ var count = ViewModel.SwitcherWindows.Count;
+ if (count == 0) { FirstRow.Focus(FocusState.Keyboard); return; }
+ var next = Math.Clamp(index, 0, count - 1);
+ SwitcherList.UpdateLayout();
+ if (SwitcherList.ContainerFromIndex(next) is { } container
+ && FocusManager.FindFirstFocusableElement(container) is Control card)
+ card.Focus(FocusState.Keyboard);
+ }
+
/// Windows won't hand a background process the foreground; this forces it (PlayStation pads need it).
public void BringToFront()
{
@@ -131,14 +460,18 @@ private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e)
{
switch (e.PropertyName)
{
- case nameof(MainViewModel.IsConsoleActive) when !ViewModel.IsConsoleActive:
+ // A session menu goes with its session; the preview (opened outside one) does not.
+ case nameof(MainViewModel.IsConsoleActive) when !ViewModel.IsConsoleActive && !ViewModel.IsSessionMenuPreview:
Close();
break;
case nameof(MainViewModel.IsSessionPickerOpen):
+ // Read the focus now: by the time the queued work runs, the picker already took it.
+ if (ViewModel.IsSessionPickerOpen && Root.XamlRoot is { } xamlRoot)
+ _pickerOpener = FocusManager.GetFocusedElement(xamlRoot) as Control;
DispatcherQueue.TryEnqueue(() =>
{
- if (ViewModel.IsSessionPickerOpen) FocusPickerSelection();
- else FirstRow.Focus(FocusState.Keyboard);
+ if (ViewModel.IsSessionPickerOpen) { PlayPickerEntrance(); FocusPickerSelection(); }
+ else (_pickerOpener ?? FirstRow).Focus(FocusState.Keyboard); // back on the row that opened it
});
break;
}
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 b8c6008..072fa96 100644
--- a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs
+++ b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs
@@ -10,7 +10,9 @@
namespace ConsoleMode.ViewModels;
// The in-session menu (Select + Y): a window over the game with volume, resolution, audio,
-// FPS, HDR and the two ways out. Everything here runs only while a session is active.
+// FPS, HDR and the two ways out. In this alpha it also opens outside a session ("preview"), on the
+// game screen or the primary one, so it can be tried without entering console mode: nothing that
+// needs a restore afterwards (the FPS limit) is offered there.
public partial class MainViewModel
{
private SessionMenuWindow? _sessionMenu;
@@ -21,6 +23,7 @@ public partial class MainViewModel
[ObservableProperty] private bool _isSessionMenuOpen;
[ObservableProperty] private bool _isSessionMenuBusy;
+ [ObservableProperty] private bool _isSessionMenuPreview;
[ObservableProperty] private bool _isSessionPickerOpen;
[ObservableProperty] private string _sessionPickerTitle = "";
[ObservableProperty] private string _sessionClock = "";
@@ -33,7 +36,25 @@ public partial class MainViewModel
[ObservableProperty] private string _sessionAudioText = "";
[ObservableProperty] private string _sessionFpsText = "";
- public bool IsFpsMenuAvailable => Engine.Rtss.IsReady;
+ public bool IsFpsMenuAvailable => Engine.Rtss.IsReady && !IsSessionMenuPreview;
+ public bool IsSessionMenuLive => !IsSessionMenuPreview;
+
+ /// "Back to the PC" in a session; "Close menu" in the preview, where there is no desk to restore.
+ public string BackToPcText => LocalizationService.Get(IsSessionMenuPreview ? "CloseMenu" : "BackToPc");
+
+ /// The screen the menu acts on: the session's game screen, or in the preview the chosen game screen if it is on, else the primary one.
+ private string SessionMonitorName => IsConsoleActive
+ ? Engine.State.FocusMonitor ?? ""
+ : FocusRow?.Monitor is { IsActive: true } game ? game.Name
+ // The chosen game screen is often the TV, which is off until a session starts: use a live one.
+ : (Monitors.FirstOrDefault(m => m.Monitor.IsPrimary && m.Monitor.IsActive) ?? Monitors.FirstOrDefault(m => m.Monitor.IsActive))?.Monitor.Name ?? "";
+
+ partial void OnIsSessionMenuPreviewChanged(bool value)
+ {
+ OnPropertyChanged(nameof(IsFpsMenuAvailable));
+ OnPropertyChanged(nameof(IsSessionMenuLive));
+ OnPropertyChanged(nameof(BackToPcText));
+ }
public string VolumeText => VolumePercent < 0 ? "—" : IsMuted ? LocalizationService.Get("Muted") : $"{VolumePercent}%";
public string RecordRowText => LocalizationService.Get("RecordLast30") + LocalizationService.Get("ComingSoonSuffix");
@@ -43,10 +64,23 @@ public partial class MainViewModel
/// Select + Y, the tray, or consolemode://menu.
public void ToggleSessionMenu()
{
- if (!IsConsoleActive) return;
if (IsSessionMenuOpen) { CloseSessionMenu(); return; }
- _sessionMenu = new SessionMenuWindow(this, Engine.State.FocusMonitorRect);
+ IsSessionMenuPreview = !IsConsoleActive;
+ var screen = IsConsoleActive
+ ? Engine.State.FocusMonitorRect
+ : Engine.Monitors.GetMonitorRect(SessionMonitorName, Engine.State);
+ try
+ {
+ _sessionMenu = new SessionMenuWindow(this, screen);
+ }
+ catch (Exception ex)
+ {
+ // A menu that fails to build must never take the app (and the session) down with it.
+ AppLog.Write($"Menu da sessão: não abriu: {ex}");
+ _sessionMenu = null;
+ return;
+ }
_sessionMenu.Closed += (_, _) => { _sessionMenu = null; IsSessionMenuOpen = false; StopSessionClock(); };
IsSessionMenuOpen = true;
IsSessionPickerOpen = false;
@@ -55,7 +89,8 @@ public void ToggleSessionMenu()
_sessionMenu.Activate();
_sessionMenu.BringToFront();
_ = LoadSessionValuesAsync();
- AppLog.Write("Menu da sessão: aberto");
+ _ = RefreshSwitcherAsync();
+ AppLog.Write(IsSessionMenuPreview ? "Menu da sessão: aberto (prévia, fora da sessão)" : "Menu da sessão: aberto");
}
///
@@ -87,7 +122,7 @@ private async Task ShowSessionHintAsync()
private void CloseSessionMenu()
{
IsSessionPickerOpen = false;
- _sessionMenu?.Close();
+ _sessionMenu?.CloseAnimated();
}
/// B: closes the picker first, then the menu.
@@ -118,7 +153,7 @@ private void RefreshSessionHeader()
{
SessionClock = DateTime.Now.ToString("HH:mm");
var started = Engine.State.SessionStartedAt ?? DateTime.Now;
- SessionElapsed = LocalizationService.Get("SessionElapsed", SessionMenuMath.FormatElapsed(DateTime.Now - started));
+ SessionElapsed = IsSessionMenuPreview ? "" : LocalizationService.Get("SessionElapsed", SessionMenuMath.FormatElapsed(DateTime.Now - started));
SessionControllerText = ControllerInput.DetectFamily() switch
{
ControllerFamily.Xbox => LocalizationService.Get("ControllerXbox"),
@@ -133,7 +168,7 @@ private void RefreshSessionHeader()
private async Task LoadSessionValuesAsync()
{
var state = Engine.State;
- var focus = state.FocusMonitor ?? "";
+ var focus = SessionMonitorName;
var (mode, hdr, audioName, volume) = await Task.Run(() =>
{
var current = NativeWindows.GetCurrentDisplayMode(focus);
@@ -185,7 +220,7 @@ private void OpenSessionPicker(string key)
{
if (IsSessionMenuBusy) return;
var state = Engine.State;
- var focus = state.FocusMonitor ?? "";
+ var focus = SessionMonitorName;
IEnumerable<(string Text, string Value, bool Selected)> options;
string title;
switch (key)
@@ -226,15 +261,16 @@ private async Task PickSessionOptionAsync(PickerItem? item)
await RunSessionActionAsync(() =>
{
var state = Engine.State;
+ var screen = SessionMonitorName;
switch (key)
{
case "mode":
var parts = item.Value.Split('x', '@');
- Engine.Monitors.ApplyFocusMode(state.FocusMonitor ?? "", new SavedDisplayMode
+ Engine.Monitors.ApplyFocusMode(screen, new SavedDisplayMode
{
Width = int.Parse(parts[0]), Height = int.Parse(parts[1]), Frequency = int.Parse(parts[2])
});
- Engine.Monitors.UpdateFocusRect(state.FocusMonitor ?? "", state, true);
+ Engine.Monitors.UpdateFocusRect(screen, state, true);
break;
case "audio":
Engine.Audio.SetOutput(item.Value);
@@ -253,7 +289,7 @@ await RunSessionActionAsync(() =>
[RelayCommand]
private async Task ToggleSessionHdrAsync()
{
- var focus = Engine.State.FocusMonitor ?? "";
+ var focus = SessionMonitorName;
var target = !SessionHdrOn;
await RunSessionActionAsync(() =>
{
@@ -277,15 +313,18 @@ private async Task RunSessionActionAsync(Action action)
[RelayCommand]
private async Task RestoreFromMenuAsync()
{
+ var preview = IsSessionMenuPreview;
CloseSessionMenu();
- await RestoreNowAsync();
+ // In the preview there is no session and no backup: closing is all there is to do.
+ if (!preview) await RestoreNowAsync();
}
[RelayCommand]
private async Task ExitFromMenuAsync()
{
+ var preview = IsSessionMenuPreview;
CloseSessionMenu();
- await RestoreNowAsync();
+ if (!preview) await RestoreNowAsync();
Application.Current.Exit();
}
}
diff --git a/src/ConsoleMode/ViewModels/MainViewModel.Switcher.cs b/src/ConsoleMode/ViewModels/MainViewModel.Switcher.cs
new file mode 100644
index 0000000..7c8f512
--- /dev/null
+++ b/src/ConsoleMode/ViewModels/MainViewModel.Switcher.cs
@@ -0,0 +1,86 @@
+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: the open windows, always on screen, picked with the pad.
+public partial class MainViewModel
+{
+ public ObservableCollection SwitcherWindows { get; } = [];
+
+ [ObservableProperty] private string _sessionWindowsText = "";
+
+ public bool SwitcherIsEmpty => SwitcherWindows.Count == 0;
+
+ /// 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();
+ 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")}");
+ if (ok) 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..1db6c6f
--- /dev/null
+++ b/tests/ConsoleMode.Tests/AltTabRulesTests.cs
@@ -0,0 +1,51 @@
+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("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 7c3e5d9..4304477 100644
--- a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj
+++ b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj
@@ -36,6 +36,7 @@
+