diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md
index 1d2e849..20fca98 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
+- Guided tour of the session menu: the first time the menu opens, it offers an optional tour that highlights each option and explains what it does, including the difference between **Back to the PC** (restores the desk and the app stays in the tray) and **Exit Console Mode** (restores and closes the app). To see it again, press Y (△) or F1 in the menu, or use Settings → Session menu tour. (#144)
- Revamped FPS counter: the numbers sit on a dark rounded card (it can be turned off) in three sizes, Small, Medium and Large. You can pick the screen corner it shows in (or keep RTSS's position) and tune it all from the session menu itself: A on "FPS counter" opens the list with style, size, position, background, color and, in Custom, the items. New styles: FPS only, Compact (FPS plus CPU, GPU and RAM usage on one line) and Detailed (FPS, frame time and API, plus CPU clock, GPU clock and temperature, VRAM and RAM). In Custom, the PC items can be picked one by one too. The data comes from Windows and the graphics driver, nothing to install; anyone already on "Compact" keeps seeing just the FPS. (#146)
### Changes
diff --git a/CHANGELOG.md b/CHANGELOG.md
index f102a9f..0e14684 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
+- Tour guiado do menu da sessão: na primeira vez que o menu abre, ele oferece um tour opcional que destaca cada opção e explica o que ela faz, incluindo a diferença entre **Voltar ao PC** (restaura a mesa e o app fica na bandeja) e **Sair do Console Mode** (restaura e fecha o app). Para ver de novo, aperte Y (△) ou F1 no menu, ou use Ajustes → Tour do menu da sessão. (#144)
- Contador de FPS repaginado: os números ficam num card escuro e arredondado (dá para desligar) em três tamanhos, Pequeno, Médio e Grande. Dá para escolher o canto da tela onde ele aparece (ou deixar a posição do RTSS) e ajustar tudo pelo próprio menu da sessão: A em "Contador de FPS" abre a lista com estilo, tamanho, posição, fundo, cor e, no Personalizado, os itens. Novos estilos: Apenas FPS, Compacto (FPS e uso de CPU, GPU e RAM numa linha) e Detalhado (FPS, tempo de quadro e API, mais clock da CPU, clock e temperatura da GPU, VRAM e RAM). No Personalizado, os itens do PC também podem ser escolhidos um a um. Os dados vêm do Windows e do driver de vídeo, sem instalar nada; quem já usava "Compacto" continua vendo só o FPS. (#146)
### Mudanças
diff --git a/docs/GUIDE.md b/docs/GUIDE.md
index 94c666f..6af2638 100644
--- a/docs/GUIDE.md
+++ b/docs/GUIDE.md
@@ -12,6 +12,8 @@ Details that don't fit in the [README](../README.md). 🇧🇷 [Guia em portugu
You can also restore anytime from the tray (*Restore setup* / *Show window*). With black overlays, **ESC** dismisses the curtains.
+The session menu has two ways out. **Back to the PC** ends the session and puts screens, resolution, HDR, audio and the FPS limit back as they were; the app stays in the tray. **Exit Console Mode** does the same and then closes the app, so the controller shortcuts stop until you open it again. The first time the menu opens, it offers a tour that explains each option; to see it later, press **Y** (△ on PlayStation) or **F1** in the menu, or use **Settings → Session menu tour**.
+
## Settings in Console mode
In **System → Controller**, choose the button layout: **Automatic**, **Xbox** or **PlayStation**. The preview and shortcut labels follow the choice, which is saved when you close the app. This changes the displayed symbols; the controller's inputs keep their existing mapping.
diff --git a/docs/GUIDE.pt-BR.md b/docs/GUIDE.pt-BR.md
index bdeba5e..6958e0e 100644
--- a/docs/GUIDE.pt-BR.md
+++ b/docs/GUIDE.pt-BR.md
@@ -12,6 +12,8 @@ Detalhes que não cabem no [README](../README.pt-BR.md). 🇺🇸 [Guide in Engl
Também dá para restaurar a qualquer momento pela bandeja (*Restaurar setup* / *Mostrar janela*). Com cortinas pretas, **ESC** remove o overlay.
+No menu da sessão há duas saídas. **Voltar ao PC** encerra a sessão e devolve telas, resolução, HDR, áudio e limite de FPS ao que eram; o app continua na bandeja. **Sair do Console Mode** faz o mesmo e depois fecha o app, então os atalhos do controle param até você abri-lo de novo. Na primeira vez que o menu abre, ele oferece um tour que explica cada opção; para vê-lo depois, aperte **Y** (△ no PlayStation) ou **F1** no menu, ou use **Ajustes → Tour do menu da sessão**.
+
## Configurações no modo Console
Em **Sistema → Controle**, escolha o layout dos botões: **Automático**, **Xbox** ou **PlayStation**. A prévia e os nomes dos atalhos acompanham a escolha, que fica salva ao fechar o app. Isso muda os símbolos exibidos; as entradas do controle mantêm o mapeamento existente.
diff --git a/docs/TESTING.md b/docs/TESTING.md
index 0e4c4d0..7fa9aed 100644
--- a/docs/TESTING.md
+++ b/docs/TESTING.md
@@ -157,6 +157,24 @@ Pré-condições: sessão ativa com Big Picture (ou jogo borderless) na tela; co
- [ ] Jogo em **tela cheia exclusiva**: o menu não aparece por cima (limitação documentada); Select + Y não quebra nada. Resultado: ______
- [ ] `start consolemode://menu` no cmd abre o menu; fora da sessão, não faz nada. Resultado: ______
+### Tour guiado do menu
+
+Pré-condição para o convite: `SessionMenuTourDone` ausente ou `false` no `config.json` da pasta de dados.
+
+- [ ] **Convite na primeira vez**: ao abrir o menu (sessão ou prévia), depois que as linhas terminam de entrar, aparece um cartão centralizado "Primeira vez neste menu?", com "Agora não" (B) e "Fazer o tour" (A, em foco). A dica do convite mostra o botão certo (Y no Xbox, △ no PlayStation). Resultado: ______
+- [ ] **Agora não** (ou B): o cartão some, o foco volta para "Voltar ao jogo" e o convite **não** aparece mais ao reabrir o menu (`SessionMenuTourDone: true` no config). Resultado: ______
+- [ ] **Fechar o menu com o convite aberto** (pelo atalho): o convite aparece de novo na próxima abertura. Resultado: ______
+- [ ] **Paradas**: A avança por Voltar ao jogo → Explorador de arquivos → Volume → Tela e som → FPS (só com RTSS e fora da prévia) → Janelas → Voltar ao PC (ou "Fechar menu" na prévia) → Sair do Console Mode. Cada parada escurece o resto do menu, contorna na cor de destaque só as linhas certas (resolução e áudio juntas e o HDR à parte quando as linhas de FPS estão entre eles) e mostra "n de N". Resultado: ______
+- [ ] **Balão**: fica à direita do painel lateral, na altura das linhas destacadas, sem sair da tela; na parada Janelas fica sobre o painel lateral. Os textos não ficam cortados a 1080p nem a 4K com escala. Resultado: ______
+- [ ] **Rolagem**: numa tela em que o painel lateral precisa rolar (1080p com escala de 125%+), as paradas Voltar ao PC e Sair do Console Mode rolam o painel até as linhas e o destaque acompanha. Resultado: ______
+- [ ] **Textos das saídas**: em sessão, "Voltar ao PC" explica que restaura e o app fica na bandeja, e "Sair do Console Mode" que também fecha o app; na prévia, "Fechar menu" diz que só fecha o menu. Resultado: ______
+- [ ] **Controle no tour**: A e → avançam, ← volta uma parada (nunca para o convite), B sai; nenhum botão aciona a opção destacada nem mexe no menu por trás (X não fecha janela, Y não reinicia o tour). Na última parada o botão diz "Entendi" e encerra. Resultado: ______
+- [ ] **Teclado e mouse no tour**: Enter/Espaço acionam o botão em foco no balão, Tab alterna entre os dois, ←/→ trocam de parada, Esc sai; clicar numa linha destacada não faz nada. Resultado: ______
+- [ ] **Ver de novo**: Y (△) ou F1 com o menu aberto inicia o tour direto, sem convite; a barra de dicas mostra "Y Tour". Com o seletor ou a confirmação de fechar janela aberta, Y não faz nada. Resultado: ______
+- [ ] **Select + Y não abre o tour**: com o atalho do menu em Select + Y, segurar o atalho para **fechar** o menu não mostra o tour antes de fechar. Resultado: ______
+- [ ] **Pelas Configurações**: Ajustes → "Tour do menu da sessão" → "Ver o tour" (desktop) e Sistema → "Tour do menu da sessão" (interface Console) abrem o menu em prévia já com o tour, sem convite. Resultado: ______
+- [ ] Ao sair do tour, o foco volta para a linha ou o card onde estava antes. Sem animações do Windows, o tour aparece e troca de parada sem movimento. Em alto contraste, o véu e o balão usam as cores do sistema. Resultado: ______
+
## 1d. Explorador de arquivos (ControlFS) no menu da sessão
Pré-condição: ControlFS instalado (https://github.com/nextestudios/ControlFS), exceto nos itens de instalação pelo menu.
diff --git a/src/ConsoleMode/Models/Models.cs b/src/ConsoleMode/Models/Models.cs
index c557109..cd5101b 100644
--- a/src/ConsoleMode/Models/Models.cs
+++ b/src/ConsoleMode/Models/Models.cs
@@ -36,6 +36,9 @@ public sealed class AppConfig
/// The first-run tour was finished or skipped.
public bool TourDone { get; set; }
+ /// The session menu's tour was taken, finished or declined: it is no longer offered when the menu opens.
+ public bool SessionMenuTourDone { get; set; }
+
///
/// Game screen + mode the user confirmed they can see ("id|WxH@Hz"). A different setup
/// asks for confirmation on the TV before launching, and rolls back if nobody answers.
diff --git a/src/ConsoleMode/Resources/Strings.en-US.json b/src/ConsoleMode/Resources/Strings.en-US.json
index eebeffe..711c8ce 100644
--- a/src/ConsoleMode/Resources/Strings.en-US.json
+++ b/src/ConsoleMode/Resources/Strings.en-US.json
@@ -349,5 +349,27 @@
"RestoreSetup": "Restore setup",
"Exit": "Exit",
"ToggleOn": "On",
- "ToggleOff": "Off"
+ "ToggleOff": "Off",
+ "SessionTourInviteTitle": "First time in this menu?",
+ "SessionTourInviteBody": "A quick tour shows what each option does, including the difference between Back to the PC and Exit Console Mode. To see it later, press {0} here in the menu.",
+ "SessionTourStart": "Take the tour",
+ "SessionTourNotNow": "Not now",
+ "SessionTourProgress": "{0} of {1}",
+ "SessionTourHint": "Tour",
+ "SessionTourBackToGameBody": "Closes this menu and hands the controller back to the game, just as it was. {0} does the same from anywhere in the menu.",
+ "SessionTourControlFsBody": "Opens ControlFS, a file explorer made for the controller, in full screen. If it isn't installed yet, the first press downloads and installs it.",
+ "SessionTourVolumeBody": "Press {0} to adjust the volume of the audio output: ◀ ▶ change it in steps of 5, {1} mutes it, and {0} or {2} finish.",
+ "SessionTourDisplayTitle": "Screen and sound",
+ "SessionTourDisplayBody": "Resolution, refresh rate, audio output and HDR of the game screen. Whatever you change here lasts for this session only: back at the PC, everything goes back to how it was.",
+ "SessionTourDisplayPreviewBody": "Resolution, refresh rate, audio output and HDR of the game screen. Outside a session, like now, whatever you change here stays until you change it again.",
+ "SessionTourFpsTitle": "FPS",
+ "SessionTourFpsBody": "The FPS limit caps the frame rate of your games and the counter shows the FPS in a corner of the screen, both through RivaTuner. Back at the PC, both are undone.",
+ "SessionTourWindowsBody": "The open programs, like an Alt + Tab that is always on screen. {0} brings a window to the front; {1} asks it to close, once you confirm.",
+ "SessionTourBackToPcBody": "Ends the session: screens, resolution, HDR, audio and the FPS limit go back to how they were, Big Picture closes and the TV turns off if you set that up. Console Mode stays open in the tray, ready for next time.",
+ "SessionTourCloseMenuBody": "Outside a session there is nothing to restore, so this button only closes the menu. During a session it becomes Back to the PC and puts screens, audio and resolution back as they were.",
+ "SessionTourExitBody": "Does everything Back to the PC does and also closes Console Mode: it leaves the tray and the controller shortcuts stop working until you open it again.",
+ "SessionTourExitPreviewBody": "Closes Console Mode completely: it leaves the tray and the controller shortcuts stop working until you open it again.",
+ "SessionTourCard": "Session menu tour",
+ "SessionTourDescription": "Explains each option of the menu over the game. Opens the menu as a preview, without starting a session.",
+ "ShowTour": "Show the tour"
}
diff --git a/src/ConsoleMode/Resources/Strings.es-ES.json b/src/ConsoleMode/Resources/Strings.es-ES.json
index d957a35..2dfef48 100644
--- a/src/ConsoleMode/Resources/Strings.es-ES.json
+++ b/src/ConsoleMode/Resources/Strings.es-ES.json
@@ -349,5 +349,27 @@
"RestoreSetup": "Restaurar pantallas",
"Exit": "Salir",
"ToggleOn": "Activado",
- "ToggleOff": "Desactivado"
+ "ToggleOff": "Desactivado",
+ "SessionTourInviteTitle": "¿Primera vez en este menú?",
+ "SessionTourInviteBody": "Un recorrido rápido muestra qué hace cada opción, incluida la diferencia entre Volver al PC y Salir de Console Mode. Para verlo más tarde, pulsa {0} aquí en el menú.",
+ "SessionTourStart": "Hacer el recorrido",
+ "SessionTourNotNow": "Ahora no",
+ "SessionTourProgress": "{0} de {1}",
+ "SessionTourHint": "Recorrido",
+ "SessionTourBackToGameBody": "Cierra este menú y devuelve el mando al juego, tal como estaba. {0} hace lo mismo desde cualquier punto del menú.",
+ "SessionTourControlFsBody": "Abre ControlFS, un explorador de archivos hecho para el mando, a pantalla completa. Si todavía no está instalado, la primera pulsación lo descarga e instala.",
+ "SessionTourVolumeBody": "Pulsa {0} para ajustar el volumen de la salida de audio: ◀ ▶ lo cambian de 5 en 5, {1} lo silencia y {0} o {2} terminan el ajuste.",
+ "SessionTourDisplayTitle": "Pantalla y sonido",
+ "SessionTourDisplayBody": "Resolución, frecuencia de actualización, salida de audio y HDR de la pantalla de juego. Lo que cambies aquí vale solo para esta sesión: al volver al PC, todo vuelve a ser como era.",
+ "SessionTourDisplayPreviewBody": "Resolución, frecuencia de actualización, salida de audio y HDR de la pantalla de juego. Fuera de una sesión, como ahora, lo que cambies aquí se queda así hasta que lo cambies de nuevo.",
+ "SessionTourFpsTitle": "FPS",
+ "SessionTourFpsBody": "El límite de FPS fija la tasa de fotogramas de los juegos y el contador muestra los FPS en una esquina de la pantalla, los dos mediante RivaTuner. Al volver al PC, los dos se deshacen.",
+ "SessionTourWindowsBody": "Los programas abiertos, como un Alt + Tab siempre en pantalla. {0} trae la ventana al frente; {1} le pide que se cierre, después de que lo confirmes.",
+ "SessionTourBackToPcBody": "Termina la sesión: pantallas, resolución, HDR, audio y límite de FPS vuelven a ser como eran, Big Picture se cierra y la TV se apaga, si lo configuraste. Console Mode sigue abierto en la bandeja, listo para la próxima.",
+ "SessionTourCloseMenuBody": "Fuera de una sesión no hay nada que restaurar, así que este botón solo cierra el menú. Durante una sesión se convierte en Volver al PC y devuelve pantallas, audio y resolución a como estaban.",
+ "SessionTourExitBody": "Hace todo lo que hace Volver al PC y, además, cierra Console Mode: sale de la bandeja y los atajos del mando dejan de funcionar hasta que lo abras de nuevo.",
+ "SessionTourExitPreviewBody": "Cierra Console Mode por completo: sale de la bandeja y los atajos del mando dejan de funcionar hasta que lo abras de nuevo.",
+ "SessionTourCard": "Recorrido del menú de sesión",
+ "SessionTourDescription": "Explica cada opción del menú sobre el juego. Abre el menú como vista previa, sin iniciar una sesión.",
+ "ShowTour": "Ver el recorrido"
}
diff --git a/src/ConsoleMode/Resources/Strings.pt-BR.json b/src/ConsoleMode/Resources/Strings.pt-BR.json
index 85d3ca2..42b97de 100644
--- a/src/ConsoleMode/Resources/Strings.pt-BR.json
+++ b/src/ConsoleMode/Resources/Strings.pt-BR.json
@@ -349,5 +349,27 @@
"RestoreSetup": "Restaurar setup",
"Exit": "Sair",
"ToggleOn": "Ativado",
- "ToggleOff": "Desativado"
+ "ToggleOff": "Desativado",
+ "SessionTourInviteTitle": "Primeira vez neste menu?",
+ "SessionTourInviteBody": "Um tour rápido mostra o que cada opção faz, incluindo a diferença entre Voltar ao PC e Sair do Console Mode. Para ver depois, aperte {0} aqui no menu.",
+ "SessionTourStart": "Fazer o tour",
+ "SessionTourNotNow": "Agora não",
+ "SessionTourProgress": "{0} de {1}",
+ "SessionTourHint": "Tour",
+ "SessionTourBackToGameBody": "Fecha este menu e devolve o controle ao jogo, do jeito que estava. O {0} faz o mesmo de qualquer ponto do menu.",
+ "SessionTourControlFsBody": "Abre o ControlFS, um explorador de arquivos feito para o controle, em tela cheia. Se ele ainda não estiver instalado, o primeiro clique baixa e instala.",
+ "SessionTourVolumeBody": "Aperte {0} para ajustar o volume da saída de áudio: ◀ ▶ mudam de 5 em 5, {1} deixa no mudo e {0} ou {2} terminam o ajuste.",
+ "SessionTourDisplayTitle": "Tela e som",
+ "SessionTourDisplayBody": "Resolução, taxa de atualização, saída de áudio e HDR da tela de jogo. O que você mudar aqui vale só para esta sessão: ao voltar ao PC, tudo volta a ser como era.",
+ "SessionTourDisplayPreviewBody": "Resolução, taxa de atualização, saída de áudio e HDR da tela de jogo. Fora de uma sessão, como agora, o que você mudar aqui fica assim até ser trocado de novo.",
+ "SessionTourFpsTitle": "FPS",
+ "SessionTourFpsBody": "O limite de FPS trava a taxa de quadros dos jogos e o contador mostra os FPS num canto da tela, os dois pelo RivaTuner. Ao voltar ao PC, os dois são desfeitos.",
+ "SessionTourWindowsBody": "Os programas abertos, como um Alt + Tab que fica sempre na tela. {0} traz a janela para a frente; {1} pede para ela fechar, depois de você confirmar.",
+ "SessionTourBackToPcBody": "Encerra a sessão: telas, resolução, HDR, áudio e limite de FPS voltam ao que eram antes, o Big Picture fecha e a TV desliga, se você configurou isso. O Console Mode continua aberto na bandeja, pronto para a próxima.",
+ "SessionTourCloseMenuBody": "Fora de uma sessão não há nada para restaurar, então este botão só fecha o menu. Durante uma sessão ele vira Voltar ao PC e devolve telas, áudio e resolução ao que eram.",
+ "SessionTourExitBody": "Faz tudo o que Voltar ao PC faz e, além disso, fecha o Console Mode: ele sai da bandeja e os atalhos do controle param de funcionar até você abri-lo de novo.",
+ "SessionTourExitPreviewBody": "Fecha o Console Mode por completo: ele sai da bandeja e os atalhos do controle param de funcionar até você abri-lo de novo.",
+ "SessionTourCard": "Tour do menu da sessão",
+ "SessionTourDescription": "Explica cada opção do menu sobre o jogo. Abre o menu numa prévia, sem iniciar uma sessão.",
+ "ShowTour": "Ver o tour"
}
diff --git a/src/ConsoleMode/Services/LocalizationService.cs b/src/ConsoleMode/Services/LocalizationService.cs
index c3ce7ba..49aacd3 100644
--- a/src/ConsoleMode/Services/LocalizationService.cs
+++ b/src/ConsoleMode/Services/LocalizationService.cs
@@ -269,6 +269,10 @@ internal void NotifyAll()
public string TutorialCard => LocalizationService.Get(nameof(TutorialCard));
public string TutorialDescription => LocalizationService.Get(nameof(TutorialDescription));
public string SeeAgain => LocalizationService.Get(nameof(SeeAgain));
+ public string SessionTourCard => LocalizationService.Get(nameof(SessionTourCard));
+ public string SessionTourDescription => LocalizationService.Get(nameof(SessionTourDescription));
+ public string SessionTourHint => LocalizationService.Get(nameof(SessionTourHint));
+ public string ShowTour => LocalizationService.Get(nameof(ShowTour));
public string OneClickCard => LocalizationService.Get(nameof(OneClickCard));
public string OneClickDescription => LocalizationService.Get(nameof(OneClickDescription));
public string CreateShortcut => LocalizationService.Get(nameof(CreateShortcut));
diff --git a/src/ConsoleMode/Services/SessionMenuTour.cs b/src/ConsoleMode/Services/SessionMenuTour.cs
new file mode 100644
index 0000000..527d941
--- /dev/null
+++ b/src/ConsoleMode/Services/SessionMenuTour.cs
@@ -0,0 +1,86 @@
+namespace ConsoleMode.Services;
+
+/// The stops of the session menu's guided tour, in the order they come.
+public enum SessionTourStop
+{
+ /// The first time the menu opens: asks whether to take the tour at all.
+ Invite,
+ BackToGame,
+ ControlFs,
+ Volume,
+ /// Resolution, audio output and HDR together.
+ Display,
+ /// FPS limit and counter together; only when the menu shows either.
+ Fps,
+ Windows,
+ BackToPc,
+ Exit
+}
+
+///
+/// What the session menu's guided tour shows: which stops it makes and the texts of each. Pure, so it
+/// is tested; the window highlights the rows of the current stop and puts these texts beside them.
+///
+public static class SessionMenuTour
+{
+ /// The stops, with the invite first when the tour is offered rather than asked for.
+ public static IReadOnlyList Stops(bool invite, bool fpsRows)
+ {
+ var stops = new List();
+ if (invite) stops.Add(SessionTourStop.Invite);
+ stops.AddRange([SessionTourStop.BackToGame, SessionTourStop.ControlFs, SessionTourStop.Volume, SessionTourStop.Display]);
+ if (fpsRows) stops.Add(SessionTourStop.Fps);
+ stops.AddRange([SessionTourStop.Windows, SessionTourStop.BackToPc, SessionTourStop.Exit]);
+ return stops;
+ }
+
+ /// "3 of 8": the position among the real stops (the invite doesn't count); null on the invite.
+ public static (int Number, int Total)? Progress(IReadOnlyList stops, int index)
+ {
+ if (index < 0 || index >= stops.Count || stops[index] == SessionTourStop.Invite) return null;
+ var offset = stops[0] == SessionTourStop.Invite ? 1 : 0;
+ return (index - offset + 1, stops.Count - offset);
+ }
+
+ /// The title of a stop: the row's own label where there is one, so the tour names what is on screen.
+ public static string TitleKey(SessionTourStop stop, bool preview) => stop switch
+ {
+ SessionTourStop.Invite => "SessionTourInviteTitle",
+ SessionTourStop.BackToGame => "BackToGame",
+ SessionTourStop.ControlFs => "ControlFsCard",
+ SessionTourStop.Volume => "VolumeRow",
+ SessionTourStop.Display => "SessionTourDisplayTitle",
+ SessionTourStop.Fps => "SessionTourFpsTitle",
+ SessionTourStop.Windows => "SessionWindowsCard",
+ SessionTourStop.BackToPc => preview ? "CloseMenu" : "BackToPc",
+ _ => "ExitConsoleMode"
+ };
+
+ ///
+ /// What a stop explains. Outside a session (the preview) nothing is restored afterwards, so the stops
+ /// about changes and about the ways out say what happens there instead.
+ ///
+ public static string BodyKey(SessionTourStop stop, bool preview) => stop switch
+ {
+ SessionTourStop.Invite => "SessionTourInviteBody",
+ SessionTourStop.BackToGame => "SessionTourBackToGameBody",
+ SessionTourStop.ControlFs => "SessionTourControlFsBody",
+ SessionTourStop.Volume => "SessionTourVolumeBody",
+ SessionTourStop.Display => preview ? "SessionTourDisplayPreviewBody" : "SessionTourDisplayBody",
+ SessionTourStop.Fps => "SessionTourFpsBody",
+ SessionTourStop.Windows => "SessionTourWindowsBody",
+ SessionTourStop.BackToPc => preview ? "SessionTourCloseMenuBody" : "SessionTourBackToPcBody",
+ _ => preview ? "SessionTourExitPreviewBody" : "SessionTourExitBody"
+ };
+
+ ///
+ /// Y opens the tour, unless it is part of a controller shortcut being held right now: Select + Y is the
+ /// suggested way to close this very menu, and that press must not start a tour on its way out.
+ ///
+ public static bool IsTourPress(IEnumerable shortcuts, IEnumerable deviceStates)
+ {
+ var states = deviceStates.ToList();
+ return !shortcuts.Any(mask => (mask & ControllerShortcuts.Y) != 0
+ && ControllerShortcuts.IsHeldOnAnyDevice(mask, states));
+ }
+}
diff --git a/src/ConsoleMode/SessionMenuWindow.xaml b/src/ConsoleMode/SessionMenuWindow.xaml
index fe5d934..6dfce27 100644
--- a/src/ConsoleMode/SessionMenuWindow.xaml
+++ b/src/ConsoleMode/SessionMenuWindow.xaml
@@ -27,6 +27,7 @@
+
@@ -38,6 +39,7 @@
+
+
+
@@ -166,7 +174,7 @@
-
+
-
@@ -412,6 +424,49 @@
+
+
+
+
+
+
+
diff --git a/src/ConsoleMode/SessionMenuWindow.xaml.cs b/src/ConsoleMode/SessionMenuWindow.xaml.cs
index ca93580..df5c4ac 100644
--- a/src/ConsoleMode/SessionMenuWindow.xaml.cs
+++ b/src/ConsoleMode/SessionMenuWindow.xaml.cs
@@ -81,6 +81,8 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target)
SearchRoot = () => CloseConfirmOverlay.Visibility == Visibility.Visible ? CloseConfirmCard
: ViewModel.IsSessionPickerOpen ? PickerCard
: _lastDirection is FocusNavigationDirection.Up or FocusNavigationDirection.Down ? RegionOfFocus() : Root,
+ // The tour takes every press while it is up: A next, Left/Right between stops, B leaves it.
+ Intercept = OnTourPress,
// Adjust mode on the volume row: Left/Right change it, Up/Down are swallowed.
BeforeMove = direction =>
{
@@ -127,12 +129,26 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target)
else if (_editingVolume) SetEditingVolume(false);
else ViewModel.SessionMenuBack();
};
+ // Y / Triangle: the tour, on demand.
+ _navigator.AltRequested += RequestTourFromPad;
_navigator.Start();
// PreviewKeyDown (tunneling): the ScrollViewer would otherwise handle the arrows as scrolling
// before they ever bubble up to Root.
Root.PreviewKeyDown += (_, e) =>
{
+ if (ViewModel.IsSessionTourOpen)
+ {
+ e.Handled = OnTourKey(e.Key);
+ return;
+ }
+ // F1: the tour, like Y on the pad.
+ if (e.Key == Windows.System.VirtualKey.F1)
+ {
+ e.Handled = true;
+ StartTourOnDemand();
+ return;
+ }
if (CloseConfirmOverlay.Visibility == Visibility.Visible)
{
var confirmDirection = e.Key switch
@@ -209,7 +225,13 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target)
{
FirstRow.Focus(FocusState.Keyboard);
if (Animate) PlayEntrance();
+ // The tour (offered, or asked for from Settings) waits for the rows to finish coming in.
+ AfterDelay(Animate ? EntranceDuration : TimeSpan.Zero, () => { if (!_closing) ViewModel.OnSessionMenuShown(); });
};
+ TourOverlay.SizeChanged += (_, _) => LayoutTour();
+ TourBubble.SizeChanged += (_, _) => LayoutTour();
+ RowsScroller.ViewChanged += (_, _) => LayoutTour();
+ RowsPanel.SizeChanged += (_, _) => LayoutTour();
Activated += (_, args) =>
{
// XamlRoot is null on the very first activation; GetFocusedElement(null) throws.
@@ -222,6 +244,7 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target)
ViewModel.PropertyChanged -= OnViewModelChanged;
ViewModel.SessionPickerRefocus -= FocusPickerSelection;
Ui.ColorValuesChanged -= OnColorValuesChanged;
+ _tourTimer?.Stop();
_navigator.Dispose();
};
}
@@ -439,6 +462,300 @@ private void OnPointerPressed(object sender, PointerRoutedEventArgs e)
}
}
+ // ── Guided tour ──────────────────────────────────────────────────────────────────────────
+
+ private const double TourHolePadding = 6;
+ private const double TourHoleRadius = 18;
+ private const double TourMargin = 32;
+ /// How long Y waits to tell a press of its own from Select + Y, the suggested shortcut that closes this menu.
+ private static readonly TimeSpan TourChordWait = TimeSpan.FromMilliseconds(150);
+ private Microsoft.UI.Dispatching.DispatcherQueueTimer? _tourTimer;
+ private Control? _tourOpener;
+
+ /// When the entrance is over: the last row starts 120 + 40 ms per row in and takes 260 (PlayEntrance).
+ private TimeSpan EntranceDuration => TimeSpan.FromMilliseconds(120 + RowsPanel.Children.Count * 40 + 260);
+
+ /// The rows (or the panel) each stop is about.
+ private FrameworkElement[] TourTargets(SessionTourStop stop) => stop switch
+ {
+ SessionTourStop.BackToGame => [FirstRow],
+ SessionTourStop.ControlFs => [ControlFsRow],
+ SessionTourStop.Volume => [VolumeRow],
+ SessionTourStop.Display => [ModeRow, AudioRow, HdrRow],
+ SessionTourStop.Fps => [FpsRow, FpsOverlayRow],
+ SessionTourStop.Windows => [WindowsPanel],
+ SessionTourStop.BackToPc => [BackToPcRow],
+ SessionTourStop.Exit => [ExitRow],
+ _ => []
+ };
+
+ /// Runs on the UI thread after ; a newer call replaces a pending one.
+ private void AfterDelay(TimeSpan delay, Action action)
+ {
+ _tourTimer?.Stop();
+ _tourTimer = DispatcherQueue.CreateTimer();
+ _tourTimer.Interval = delay > TimeSpan.Zero ? delay : TimeSpan.FromMilliseconds(1);
+ _tourTimer.IsRepeating = false;
+ _tourTimer.Tick += (timer, _) =>
+ {
+ timer.Stop();
+ action();
+ };
+ _tourTimer.Start();
+ }
+
+ /// Y: the tour, a moment later and only if Y was not part of a shortcut being held.
+ private void RequestTourFromPad()
+ {
+ if (ViewModel.IsSessionTourOpen) return;
+ AfterDelay(TourChordWait, () =>
+ {
+ var shortcuts = ControllerShortcuts.Slots.Select(ViewModel.GetShortcut);
+ if (SessionMenuTour.IsTourPress(shortcuts, ControllerHoldWatcher.ReadHeldByDevice().Values)) StartTourOnDemand();
+ });
+ }
+
+ /// Y or F1: the tour without the invite, unless something else is open over the menu.
+ private void StartTourOnDemand()
+ {
+ if (_closing || ViewModel.IsSessionTourOpen || ViewModel.IsSessionPickerOpen
+ || CloseConfirmOverlay.Visibility == Visibility.Visible) return;
+ UiSounds.Play(UiSound.Confirm);
+ ViewModel.StartSessionTour(invite: false);
+ }
+
+ /// The pad while the tour is up: A next, Right next (not on the invite), Left back, B leaves; the rest is swallowed.
+ private bool OnTourPress(ControllerAction action)
+ {
+ if (!ViewModel.IsSessionTourOpen) return false;
+ switch (action)
+ {
+ case ControllerAction.Confirm:
+ UiSounds.Play(UiSound.Confirm);
+ ViewModel.SessionTourNextCommand.Execute(null);
+ break;
+ case ControllerAction.Right when !ViewModel.IsSessionTourInvite:
+ UiSounds.Play(UiSound.Move);
+ ViewModel.SessionTourNextCommand.Execute(null);
+ break;
+ case ControllerAction.Left:
+ if (ViewModel.SessionTourPrevious()) UiSounds.Play(UiSound.Move);
+ break;
+ case ControllerAction.Back:
+ UiSounds.Play(UiSound.Back);
+ ViewModel.EndSessionTourCommand.Execute(null);
+ break;
+ }
+ return true;
+ }
+
+ /// The keyboard while the tour is up; Enter and Space go to the focused button of the bubble. True when handled.
+ private bool OnTourKey(Windows.System.VirtualKey key)
+ {
+ switch (key)
+ {
+ case Windows.System.VirtualKey.Enter or Windows.System.VirtualKey.Space:
+ return false;
+ case Windows.System.VirtualKey.Right when !ViewModel.IsSessionTourInvite:
+ ViewModel.SessionTourNextCommand.Execute(null);
+ break;
+ case Windows.System.VirtualKey.Left:
+ ViewModel.SessionTourPrevious();
+ break;
+ case Windows.System.VirtualKey.Escape or Windows.System.VirtualKey.Back:
+ ViewModel.EndSessionTourCommand.Execute(null);
+ break;
+ case Windows.System.VirtualKey.Tab:
+ var focused = Root.XamlRoot is { } root ? FocusManager.GetFocusedElement(root) : null;
+ (ReferenceEquals(focused, TourPrimaryButton) ? TourSecondaryButton : TourPrimaryButton).Focus(FocusState.Keyboard);
+ break;
+ }
+ // Everything else (Up, Down, Delete...) would reach the menu underneath.
+ return true;
+ }
+
+ /// Shows the current stop, or puts the tour away and gives the focus back to where it was.
+ private void ShowTourStop()
+ {
+ if (!ViewModel.IsSessionTourOpen)
+ {
+ if (TourOverlay.Visibility == Visibility.Collapsed) return;
+ TourOverlay.Visibility = Visibility.Collapsed;
+ TourRings.Children.Clear();
+ if (!_closing) (_tourOpener ?? FirstRow).Focus(FocusState.Keyboard);
+ _tourOpener = null;
+ return;
+ }
+
+ var opening = TourOverlay.Visibility == Visibility.Collapsed;
+ if (opening)
+ {
+ _tourOpener = Root.XamlRoot is { } root ? FocusManager.GetFocusedElement(root) as Control : null;
+ SetEditingVolume(false);
+ TourOverlay.Visibility = Visibility.Visible;
+ }
+ TourProgressText.Visibility = ViewModel.IsSessionTourInvite ? Visibility.Collapsed : Visibility.Visible;
+ BringTourIntoView();
+ LayoutTour();
+ TourPrimaryButton.Focus(FocusState.Keyboard);
+ if (!Animate) return;
+ try
+ {
+ if (opening)
+ {
+ ElementCompositionPreview.GetElementVisual(TourOverlay).Opacity = 0f;
+ Enter(TourOverlay, 0f, 0f, 200, 0);
+ }
+ ElementCompositionPreview.GetElementVisual(TourBubble).Opacity = 0f;
+ Enter(TourBubble, 0f, 14f, 240, opening ? 60 : 0);
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Menu da sessão: animação: {ex.Message}");
+ ElementCompositionPreview.GetElementVisual(TourOverlay).Opacity = 1f;
+ ElementCompositionPreview.GetElementVisual(TourBubble).Opacity = 1f;
+ }
+ }
+
+ /// Scrolls the side panel so the rows of the stop are in sight: on smaller screens the ways out sit below the fold.
+ private void BringTourIntoView()
+ {
+ var rows = TourTargets(ViewModel.SessionTourStop)
+ .Where(row => row.Visibility == Visibility.Visible && IsInside(RowsPanel, row))
+ .ToList();
+ if (rows.Count == 0) return;
+ RowsPanel.UpdateLayout();
+ double Top(FrameworkElement row) => row.TransformToVisual(RowsPanel).TransformPoint(new Windows.Foundation.Point(0, 0)).Y;
+ var top = rows.Min(Top) - TourHolePadding;
+ var bottom = rows.Max(row => Top(row) + row.ActualHeight) + TourHolePadding;
+ var offset = RowsScroller.VerticalOffset;
+ var viewport = RowsScroller.ViewportHeight;
+ if (top < offset || bottom - top > viewport) offset = top;
+ else if (bottom > offset + viewport) offset = bottom - viewport;
+ else return;
+ RowsScroller.ChangeView(null, Math.Max(offset, 0), null, disableAnimation: true);
+ }
+
+ ///
+ /// Cuts a hole in the veil around the rows of the stop (rows next to each other share one), outlines it,
+ /// and puts the bubble beside it. Run again whenever something moves: the panel scrolls, the text changes.
+ ///
+ private void LayoutTour()
+ {
+ if (!ViewModel.IsSessionTourOpen || TourOverlay.Visibility != Visibility.Visible) return;
+ var width = TourOverlay.ActualWidth;
+ var height = TourOverlay.ActualHeight;
+ if (width <= 0 || height <= 0) return;
+ try
+ {
+ var scroller = BoundsInTour(RowsScroller);
+ var holes = new List();
+ foreach (var target in TourTargets(ViewModel.SessionTourStop))
+ {
+ if (target.Visibility != Visibility.Visible || target.ActualWidth <= 0 || target.ActualHeight <= 0) continue;
+ var bounds = BoundsInTour(target);
+ // A row half scrolled out of the panel: only the part in sight.
+ if (IsInside(RowsPanel, target)) bounds = Clip(bounds, scroller);
+ if (bounds.Height < 8) continue;
+ var hole = new Windows.Foundation.Rect(bounds.X - TourHolePadding, bounds.Y - TourHolePadding,
+ bounds.Width + 2 * TourHolePadding, bounds.Height + 2 * TourHolePadding);
+ // Holes that touch become one: overlapping holes would cancel out under the even-odd fill.
+ if (holes.Count > 0 && hole.Y <= holes[^1].Y + holes[^1].Height + 1)
+ {
+ var last = holes[^1];
+ var left = Math.Min(last.X, hole.X);
+ var right = Math.Max(last.X + last.Width, hole.X + hole.Width);
+ holes[^1] = new Windows.Foundation.Rect(left, last.Y, right - left, Math.Max(last.Y + last.Height, hole.Y + hole.Height) - last.Y);
+ }
+ else holes.Add(hole);
+ }
+
+ var veil = new GeometryGroup { FillRule = FillRule.EvenOdd };
+ veil.Children.Add(new RectangleGeometry { Rect = new Windows.Foundation.Rect(0, 0, width, height) });
+ TourRings.Children.Clear();
+ var ringStyle = (Style)Root.Resources["TourRing"];
+ foreach (var hole in holes)
+ {
+ veil.Children.Add(RoundedRectangle(hole, TourHoleRadius));
+ var ring = new Border { Style = ringStyle, Width = hole.Width, Height = hole.Height, CornerRadius = new CornerRadius(TourHoleRadius) };
+ Canvas.SetLeft(ring, hole.X);
+ Canvas.SetTop(ring, hole.Y);
+ TourRings.Children.Add(ring);
+ }
+ TourVeil.Data = veil;
+ PlaceTourBubble(holes, width, height);
+ }
+ catch (Exception ex)
+ {
+ AppLog.Write($"Menu da sessão: tour: {ex.Message}");
+ }
+ }
+
+ /// Beside the rows of the side panel; over the side panel when the stop is the windows; centred for the invite.
+ private void PlaceTourBubble(List holes, double width, double height)
+ {
+ var bubbleWidth = TourBubble.Width;
+ var bubbleHeight = TourBubble.ActualHeight;
+ if (bubbleHeight <= 0)
+ {
+ TourBubble.Measure(new Windows.Foundation.Size(bubbleWidth, double.PositiveInfinity));
+ bubbleHeight = TourBubble.DesiredSize.Height;
+ }
+ double x, y;
+ if (holes.Count == 0)
+ {
+ x = (width - bubbleWidth) / 2;
+ y = (height - bubbleHeight) / 2;
+ }
+ else
+ {
+ var left = holes.Min(h => h.X);
+ var top = holes.Min(h => h.Y);
+ var bottom = holes.Max(h => h.Y + h.Height);
+ var sidebar = BoundsInTour(SidebarPanel);
+ x = left >= sidebar.X + sidebar.Width
+ ? sidebar.X + (sidebar.Width - bubbleWidth) / 2
+ : sidebar.X + sidebar.Width + TourMargin;
+ y = (top + bottom - bubbleHeight) / 2;
+ }
+ Canvas.SetLeft(TourBubble, Math.Clamp(x, TourMargin, Math.Max(TourMargin, width - bubbleWidth - TourMargin)));
+ Canvas.SetTop(TourBubble, Math.Clamp(y, TourMargin, Math.Max(TourMargin, height - bubbleHeight - TourMargin)));
+ }
+
+ private Windows.Foundation.Rect BoundsInTour(FrameworkElement element) =>
+ element.TransformToVisual(TourOverlay).TransformBounds(new Windows.Foundation.Rect(0, 0, element.ActualWidth, element.ActualHeight));
+
+ private static Windows.Foundation.Rect Clip(Windows.Foundation.Rect rect, Windows.Foundation.Rect to)
+ {
+ var left = Math.Max(rect.X, to.X);
+ var top = Math.Max(rect.Y, to.Y);
+ var right = Math.Min(rect.X + rect.Width, to.X + to.Width);
+ var bottom = Math.Min(rect.Y + rect.Height, to.Y + to.Height);
+ return new Windows.Foundation.Rect(left, top, Math.Max(right - left, 0), Math.Max(bottom - top, 0));
+ }
+
+ /// A rectangle with round corners as a path: RectangleGeometry has no corner radius.
+ private static PathGeometry RoundedRectangle(Windows.Foundation.Rect rect, double radius)
+ {
+ radius = Math.Min(radius, Math.Min(rect.Width, rect.Height) / 2);
+ var corner = new Windows.Foundation.Size(radius, radius);
+ double left = rect.X, top = rect.Y, right = rect.X + rect.Width, bottom = rect.Y + rect.Height;
+ var figure = new PathFigure { StartPoint = new Windows.Foundation.Point(left + radius, top), IsClosed = true };
+ void Line(double x, double y) => figure.Segments.Add(new LineSegment { Point = new Windows.Foundation.Point(x, y) });
+ void Arc(double x, double y) => figure.Segments.Add(new ArcSegment { Point = new Windows.Foundation.Point(x, y), Size = corner, SweepDirection = SweepDirection.Clockwise });
+ Line(right - radius, top);
+ Arc(right, top + radius);
+ Line(right, bottom - radius);
+ Arc(right - radius, bottom);
+ Line(left + radius, bottom);
+ Arc(left, bottom - radius);
+ Line(left, top + radius);
+ Arc(left + radius, top);
+ var geometry = new PathGeometry();
+ geometry.Figures.Add(figure);
+ return geometry;
+ }
+
// ── Volume adjust mode ───────────────────────────────────────────────────────────────────
private bool IsVolumeFocused() =>
@@ -635,6 +952,9 @@ private void OnViewModelChanged(object? sender, PropertyChangedEventArgs e)
case nameof(MainViewModel.IsConsoleActive) when !ViewModel.IsConsoleActive && !ViewModel.IsSessionMenuPreview:
Close();
break;
+ case nameof(MainViewModel.SessionTourIndex):
+ ShowTourStop();
+ 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)
diff --git a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs
index 548568e..b5247f7 100644
--- a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs
+++ b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs
@@ -89,7 +89,7 @@ public void ToggleSessionMenu()
_sessionMenu = null;
return;
}
- _sessionMenu.Closed += (_, _) => { _sessionMenu = null; IsSessionMenuOpen = false; StopSessionClock(); };
+ _sessionMenu.Closed += (_, _) => { _sessionMenu = null; IsSessionMenuOpen = false; StopSessionClock(); DropSessionTour(); };
IsSessionMenuOpen = true;
IsSessionPickerOpen = false;
ControlFsInstalled = ControlFsService.FindExe() is not null;
diff --git a/src/ConsoleMode/ViewModels/MainViewModel.SessionTour.cs b/src/ConsoleMode/ViewModels/MainViewModel.SessionTour.cs
new file mode 100644
index 0000000..82c6c6d
--- /dev/null
+++ b/src/ConsoleMode/ViewModels/MainViewModel.SessionTour.cs
@@ -0,0 +1,141 @@
+using CommunityToolkit.Mvvm.ComponentModel;
+using CommunityToolkit.Mvvm.Input;
+using ConsoleMode.Services;
+
+namespace ConsoleMode.ViewModels;
+
+// The session menu's guided tour (SessionMenuTour): offered once, the first time the menu opens, and
+// then on Y in the menu or from Settings. The window draws it: the menu dims except for the rows of
+// the current stop, and a bubble beside them says what they do.
+public partial class MainViewModel
+{
+ private IReadOnlyList _sessionTourStops = [];
+ /// Settings asked for the tour: it starts as soon as the menu it opened is on screen.
+ private bool _sessionTourOnOpen;
+
+ /// Position in the tour's stops; -1 while there is no tour.
+ [ObservableProperty] private int _sessionTourIndex = -1;
+
+ public bool IsSessionTourOpen => SessionTourIndex >= 0 && SessionTourIndex < _sessionTourStops.Count;
+ public SessionTourStop SessionTourStop => IsSessionTourOpen ? _sessionTourStops[SessionTourIndex] : SessionTourStop.Invite;
+ public bool IsSessionTourInvite => IsSessionTourOpen && SessionTourStop == SessionTourStop.Invite;
+ private bool IsSessionTourLastStop => SessionTourIndex == _sessionTourStops.Count - 1;
+
+ public string SessionTourTitle => IsSessionTourOpen ? LocalizationService.Get(SessionMenuTour.TitleKey(SessionTourStop, IsSessionMenuPreview)) : "";
+
+ /// The explanation, with the buttons written for the pad in use.
+ public string SessionTourBody
+ {
+ get
+ {
+ if (!IsSessionTourOpen) return "";
+ var key = SessionMenuTour.BodyKey(SessionTourStop, IsSessionMenuPreview);
+ return SessionTourStop switch
+ {
+ SessionTourStop.Invite => LocalizationService.Get(key, HintAlt),
+ SessionTourStop.BackToGame => LocalizationService.Get(key, HintBack),
+ SessionTourStop.Volume => LocalizationService.Get(key, HintConfirm, HintOption, HintBack),
+ SessionTourStop.Windows => LocalizationService.Get(key, HintConfirm, HintOption),
+ _ => LocalizationService.Get(key)
+ };
+ }
+ }
+
+ public string SessionTourProgress => SessionMenuTour.Progress(_sessionTourStops, SessionTourIndex) is { } progress
+ ? LocalizationService.Get("SessionTourProgress", progress.Number, progress.Total)
+ : "";
+
+ public string SessionTourPrimaryText => LocalizationService.Get(IsSessionTourInvite ? "SessionTourStart" : IsSessionTourLastStop ? "GotIt" : "Next");
+ public string SessionTourSecondaryText => LocalizationService.Get(IsSessionTourInvite ? "SessionTourNotNow" : "SkipTour");
+
+ partial void OnSessionTourIndexChanged(int value)
+ {
+ OnPropertyChanged(nameof(IsSessionTourOpen));
+ OnPropertyChanged(nameof(SessionTourStop));
+ OnPropertyChanged(nameof(IsSessionTourInvite));
+ OnPropertyChanged(nameof(SessionTourTitle));
+ OnPropertyChanged(nameof(SessionTourBody));
+ OnPropertyChanged(nameof(SessionTourProgress));
+ OnPropertyChanged(nameof(SessionTourPrimaryText));
+ OnPropertyChanged(nameof(SessionTourSecondaryText));
+ }
+
+ /// The menu's entrance is over: start the tour Settings asked for, or offer it if it was never taken.
+ public void OnSessionMenuShown()
+ {
+ if (_sessionTourOnOpen)
+ {
+ _sessionTourOnOpen = false;
+ StartSessionTour(invite: false);
+ }
+ else if (!_loadedConfig.SessionMenuTourDone) StartSessionTour(invite: true);
+ }
+
+ /// Y in the menu, or Settings with the menu already open: the tour straight away, no invite.
+ public void StartSessionTour(bool invite)
+ {
+ if (!IsSessionMenuOpen || IsSessionTourOpen || IsSessionMenuBusy) return;
+ IsSessionPickerOpen = false;
+ _sessionTourStops = SessionMenuTour.Stops(invite, IsFpsMenuAvailable || IsFpsOverlayAvailable);
+ SessionTourIndex = 0;
+ AppLog.Write(invite ? "Menu da sessão: tour oferecido" : "Menu da sessão: tour iniciado");
+ }
+
+ /// A: the invite accepted, the next stop, or the end after the last one.
+ [RelayCommand]
+ private void SessionTourNext()
+ {
+ if (!IsSessionTourOpen) return;
+ if (IsSessionTourLastStop)
+ {
+ EndSessionTour();
+ return;
+ }
+ if (IsSessionTourInvite) AppLog.Write("Menu da sessão: tour aceito");
+ SessionTourIndex++;
+ }
+
+ /// Left: one stop back, never back to the invite. False when there is none.
+ public bool SessionTourPrevious()
+ {
+ if (!IsSessionTourOpen || SessionTourIndex == 0 || _sessionTourStops[SessionTourIndex - 1] == SessionTourStop.Invite) return false;
+ SessionTourIndex--;
+ return true;
+ }
+
+ /// B, "Skip" or "Not now", or the last stop done: the tour is not offered again (Y and Settings still show it).
+ [RelayCommand]
+ private void EndSessionTour()
+ {
+ if (!IsSessionTourOpen) return;
+ AppLog.Write(IsSessionTourInvite ? "Menu da sessão: tour recusado"
+ : IsSessionTourLastStop ? "Menu da sessão: tour concluído"
+ : $"Menu da sessão: tour encerrado em {SessionTourStop}");
+ SessionTourIndex = -1;
+ if (_loadedConfig.SessionMenuTourDone) return;
+ var config = BuildConfig();
+ config.SessionMenuTourDone = true;
+ TrySave(config);
+ }
+
+ /// The menu closed under the tour (its shortcut, the session ending): put away, still offered next time.
+ private void DropSessionTour()
+ {
+ _sessionTourOnOpen = false;
+ SessionTourIndex = -1;
+ }
+
+ /// Settings → "Show the tour": opens the menu (a preview outside a session) with the tour running.
+ [RelayCommand]
+ private void ShowSessionMenuTour()
+ {
+ if (IsSessionMenuOpen)
+ {
+ StartSessionTour(invite: false);
+ return;
+ }
+ _sessionTourOnOpen = true;
+ ToggleSessionMenu();
+ if (!IsSessionMenuOpen) _sessionTourOnOpen = false;
+ }
+}
diff --git a/src/ConsoleMode/ViewModels/MainViewModel.cs b/src/ConsoleMode/ViewModels/MainViewModel.cs
index e95eb0c..64c7fc0 100644
--- a/src/ConsoleMode/ViewModels/MainViewModel.cs
+++ b/src/ConsoleMode/ViewModels/MainViewModel.cs
@@ -914,6 +914,7 @@ private AppConfig BuildConfig()
HdrEnable = HdrEnable,
VrrEnable = VrrEnable,
TourDone = _loadedConfig.TourDone,
+ SessionMenuTourDone = _loadedConfig.SessionMenuTourDone,
ConfirmedSetup = _loadedConfig.ConfirmedSetup,
CheckUpdates = CheckUpdates,
BetaUpdates = BetaUpdates,
diff --git a/src/ConsoleMode/Views/ConsoleHomeView.xaml b/src/ConsoleMode/Views/ConsoleHomeView.xaml
index 35995a3..53e3307 100644
--- a/src/ConsoleMode/Views/ConsoleHomeView.xaml
+++ b/src/ConsoleMode/Views/ConsoleHomeView.xaml
@@ -639,6 +639,9 @@
+
+
+
diff --git a/src/ConsoleMode/Views/SettingsView.xaml b/src/ConsoleMode/Views/SettingsView.xaml
index b734ec9..98edc69 100644
--- a/src/ConsoleMode/Views/SettingsView.xaml
+++ b/src/ConsoleMode/Views/SettingsView.xaml
@@ -336,6 +336,12 @@
+
+
+
+
+
+
diff --git a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj
index b778ed0..8f607bf 100644
--- a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj
+++ b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj
@@ -30,6 +30,7 @@
+
diff --git a/tests/ConsoleMode.Tests/SessionMenuTourTests.cs b/tests/ConsoleMode.Tests/SessionMenuTourTests.cs
new file mode 100644
index 0000000..a9591f6
--- /dev/null
+++ b/tests/ConsoleMode.Tests/SessionMenuTourTests.cs
@@ -0,0 +1,89 @@
+using System.Text.Json;
+using ConsoleMode.Models;
+using ConsoleMode.Services;
+
+namespace ConsoleMode.Tests;
+
+public class SessionMenuTourTests
+{
+ [Fact]
+ public void The_offered_tour_starts_with_the_invite_and_ends_on_the_two_ways_out()
+ {
+ var stops = SessionMenuTour.Stops(invite: true, fpsRows: true);
+ Assert.Equal(SessionTourStop.Invite, stops[0]);
+ Assert.Equal([SessionTourStop.BackToPc, SessionTourStop.Exit], stops.TakeLast(2));
+ Assert.Contains(SessionTourStop.Fps, stops);
+ }
+
+ [Fact]
+ public void Asked_for_it_skips_the_invite_and_without_fps_rows_the_fps_stop()
+ {
+ var stops = SessionMenuTour.Stops(invite: false, fpsRows: false);
+ Assert.DoesNotContain(SessionTourStop.Invite, stops);
+ Assert.DoesNotContain(SessionTourStop.Fps, stops);
+ Assert.Equal(SessionTourStop.BackToGame, stops[0]);
+ }
+
+ [Fact]
+ public void Progress_counts_the_real_stops_only()
+ {
+ var offered = SessionMenuTour.Stops(invite: true, fpsRows: true);
+ Assert.Null(SessionMenuTour.Progress(offered, 0));
+ Assert.Equal((1, offered.Count - 1), SessionMenuTour.Progress(offered, 1));
+ Assert.Equal((offered.Count - 1, offered.Count - 1), SessionMenuTour.Progress(offered, offered.Count - 1));
+
+ var asked = SessionMenuTour.Stops(invite: false, fpsRows: false);
+ Assert.Equal((1, asked.Count), SessionMenuTour.Progress(asked, 0));
+ Assert.Null(SessionMenuTour.Progress(asked, -1));
+ Assert.Null(SessionMenuTour.Progress(asked, asked.Count));
+ }
+
+ [Fact]
+ public void The_ways_out_explain_what_each_one_really_does()
+ {
+ // In a session the two buttons differ in whether the app stays in the tray; in the preview there is nothing to restore.
+ Assert.Equal("BackToPc", SessionMenuTour.TitleKey(SessionTourStop.BackToPc, preview: false));
+ Assert.Equal("CloseMenu", SessionMenuTour.TitleKey(SessionTourStop.BackToPc, preview: true));
+ Assert.NotEqual(SessionMenuTour.BodyKey(SessionTourStop.BackToPc, false), SessionMenuTour.BodyKey(SessionTourStop.BackToPc, true));
+ Assert.NotEqual(SessionMenuTour.BodyKey(SessionTourStop.Exit, false), SessionMenuTour.BodyKey(SessionTourStop.Exit, true));
+ Assert.NotEqual(SessionMenuTour.BodyKey(SessionTourStop.Display, false), SessionMenuTour.BodyKey(SessionTourStop.Display, true));
+ }
+
+ [Fact]
+ public void Every_stop_has_its_texts_in_every_catalog()
+ {
+ foreach (var language in LocalizationService.SupportedLanguages.Select(l => l.Code))
+ {
+ var keys = LocalizationService.GetKeys(language).ToHashSet();
+ foreach (var stop in Enum.GetValues())
+ foreach (var preview in new[] { false, true })
+ {
+ Assert.Contains(SessionMenuTour.TitleKey(stop, preview), keys);
+ Assert.Contains(SessionMenuTour.BodyKey(stop, preview), keys);
+ }
+ }
+ }
+
+ [Fact]
+ public void Y_opens_the_tour_unless_it_is_part_of_a_shortcut_being_held()
+ {
+ const ushort selectY = ControllerShortcuts.Back | ControllerShortcuts.Y;
+ const ushort startSelect = ControllerShortcuts.Start | ControllerShortcuts.Back;
+ ushort[] shortcuts = [0, selectY, startSelect];
+
+ Assert.True(SessionMenuTour.IsTourPress(shortcuts, [ControllerShortcuts.Y]));
+ Assert.False(SessionMenuTour.IsTourPress(shortcuts, [selectY]));
+ // Another pad holding Select doesn't make it a chord.
+ Assert.True(SessionMenuTour.IsTourPress(shortcuts, [ControllerShortcuts.Y, ControllerShortcuts.Back]));
+ // Only shortcuts that use Y count: holding one without it doesn't hide this press.
+ Assert.True(SessionMenuTour.IsTourPress([0, startSelect, 0], [startSelect | ControllerShortcuts.Y]));
+ Assert.True(SessionMenuTour.IsTourPress([0, 0, 0], [selectY]));
+ }
+
+ [Fact]
+ public void An_existing_config_has_not_seen_the_session_menu_tour()
+ {
+ var config = JsonSerializer.Deserialize("{}")!;
+ Assert.False(config.SessionMenuTourDone);
+ }
+}