From 5d685f386e3cdc9891b68613291a83cddb6473e2 Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Sat, 10 Oct 2026 12:16:01 -0300 Subject: [PATCH 1/2] feat: add a guided tour to the session menu The first time the session menu opens it offers an optional tour that dims the menu, outlines the rows of each stop and explains them in a bubble beside them, including what Back to the PC and Exit Console Mode really do. Y (Triangle) or F1 in the menu and a Settings entry show it again. Y is ignored while it is part of a held shortcut, so closing the menu with Select + Y doesn't start it. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.en-US.md | 2 + CHANGELOG.md | 2 + docs/GUIDE.md | 2 + docs/GUIDE.pt-BR.md | 2 + docs/TESTING.md | 18 + src/ConsoleMode/Models/Models.cs | 3 + src/ConsoleMode/Resources/Strings.en-US.json | 24 +- src/ConsoleMode/Resources/Strings.es-ES.json | 24 +- src/ConsoleMode/Resources/Strings.pt-BR.json | 24 +- .../Services/LocalizationService.cs | 4 + src/ConsoleMode/Services/SessionMenuTour.cs | 86 +++++ src/ConsoleMode/SessionMenuWindow.xaml | 71 +++- src/ConsoleMode/SessionMenuWindow.xaml.cs | 320 ++++++++++++++++++ .../ViewModels/MainViewModel.SessionMenu.cs | 2 +- .../ViewModels/MainViewModel.SessionTour.cs | 141 ++++++++ src/ConsoleMode/ViewModels/MainViewModel.cs | 1 + src/ConsoleMode/Views/ConsoleHomeView.xaml | 3 + src/ConsoleMode/Views/SettingsView.xaml | 6 + .../ConsoleMode.Tests.csproj | 1 + .../ConsoleMode.Tests/SessionMenuTourTests.cs | 89 +++++ 20 files changed, 813 insertions(+), 12 deletions(-) create mode 100644 src/ConsoleMode/Services/SessionMenuTour.cs create mode 100644 src/ConsoleMode/ViewModels/MainViewModel.SessionTour.cs create mode 100644 tests/ConsoleMode.Tests/SessionMenuTourTests.cs diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index a0dd267..009277b 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -3,6 +3,8 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Before publishing a version, add a `## [VERSION]` section to **both** files: the workflow publishes the section matching the tag from each one and fails if either is missing. ## [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. ## [1.6.0] ### What's new diff --git a/CHANGELOG.md b/CHANGELOG.md index bf01bf8..97719b8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,8 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CHANGELOG.en-US.md`. Antes de publicar uma versão, adicione uma seção `## [VERSÃO]` com o changelog daquela versão **nos dois arquivos**. O workflow publica as duas seções correspondentes à tag na mesma release e falha se faltar alguma. ## [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. ## [1.6.0] ### Novidades 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 99f71e3..52f1e58 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 6fbd8de..0c7dad0 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 62f6435..4bc7803 100644 --- a/src/ConsoleMode/Resources/Strings.en-US.json +++ b/src/ConsoleMode/Resources/Strings.en-US.json @@ -326,5 +326,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 f76fb90..3eaaede 100644 --- a/src/ConsoleMode/Resources/Strings.es-ES.json +++ b/src/ConsoleMode/Resources/Strings.es-ES.json @@ -326,5 +326,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 5b20c6b..68e0bac 100644 --- a/src/ConsoleMode/Resources/Strings.pt-BR.json +++ b/src/ConsoleMode/Resources/Strings.pt-BR.json @@ -326,5 +326,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 e7e75a3..3b2b8a6 100644 --- a/src/ConsoleMode/Services/LocalizationService.cs +++ b/src/ConsoleMode/Services/LocalizationService.cs @@ -257,6 +257,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 aefd5a1..4617bc6 100644 --- a/src/ConsoleMode/SessionMenuWindow.xaml +++ b/src/ConsoleMode/SessionMenuWindow.xaml @@ -27,6 +27,7 @@ + @@ -38,6 +39,7 @@ + + + @@ -166,7 +174,7 @@ - + - - - - - - + + + + + + + diff --git a/src/ConsoleMode/SessionMenuWindow.xaml.cs b/src/ConsoleMode/SessionMenuWindow.xaml.cs index dc8b72e..193fbce 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 @@ -208,7 +224,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. @@ -220,6 +242,7 @@ public SessionMenuWindow(MainViewModel viewModel, ScreenRect? target) { ViewModel.PropertyChanged -= OnViewModelChanged; Ui.ColorValuesChanged -= OnColorValuesChanged; + _tourTimer?.Stop(); _navigator.Dispose(); }; } @@ -437,6 +460,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() => @@ -633,6 +950,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 3d4be07..3432705 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.SessionMenu.cs @@ -85,7 +85,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 cf4c24a..ee7acef 100644 --- a/src/ConsoleMode/Views/SettingsView.xaml +++ b/src/ConsoleMode/Views/SettingsView.xaml @@ -307,6 +307,12 @@