From 159503d70f8c505daa782205451da9e1600ddf23 Mon Sep 17 00:00:00 2001 From: Filipe Moreira Date: Sat, 10 Oct 2026 12:14:38 -0300 Subject: [PATCH 1/4] feat: hide the idle pointer and drive the mouse with the controller During a session the mouse pointer now disappears after 3 seconds without moving and comes back on the next move, for emulators and games that leave it in the middle of the TV. Windows has no per-app call for this, so the system cursors are swapped for a blank one and reloaded from the user's scheme afterwards; a marker file restores them on the next start if the app dies in between. On by default, with a toggle in both settings screens. The session menu gains "Control the mouse with the controller", for games and emulators whose menus ignore the pad: left stick moves the pointer, right stick scrolls, A clicks, A twice right-clicks and A held drags. It reads XInput and Sony HID (both work without focus), pauses while the menu is open, and lasts until the session ends. The pure parts (stick curve, wheel notches, the A gesture, Sony stick offsets) are covered by unit tests; the hardware checks are in docs/TESTING.md section 1e. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.en-US.md | 3 + CHANGELOG.md | 3 + README.md | 1 + README.pt-BR.md | 1 + docs/TESTING.md | 23 ++ src/ConsoleMode/App.xaml.cs | 1 + src/ConsoleMode/Models/Models.cs | 3 + src/ConsoleMode/Resources/Strings.en-US.json | 4 + src/ConsoleMode/Resources/Strings.es-ES.json | 4 + src/ConsoleMode/Resources/Strings.pt-BR.json | 4 + src/ConsoleMode/Services/ControllerMapping.cs | 36 ++- src/ConsoleMode/Services/ControllerMouse.cs | 240 ++++++++++++++++++ .../Services/ControllerMouseMath.cs | 197 ++++++++++++++ src/ConsoleMode/Services/CursorHider.cs | 194 ++++++++++++++ .../Services/LocalizationService.cs | 3 + src/ConsoleMode/Services/SonyHidReader.cs | 17 +- src/ConsoleMode/SessionMenuWindow.xaml | 15 ++ .../ViewModels/MainViewModel.ConsoleUi.cs | 1 + .../ViewModels/MainViewModel.Pointer.cs | 59 +++++ src/ConsoleMode/ViewModels/MainViewModel.cs | 3 + src/ConsoleMode/Views/ConsoleHomeView.xaml | 6 + src/ConsoleMode/Views/SettingsView.xaml | 7 + .../ConsoleMode.Tests.csproj | 1 + .../ControllerMappingTests.cs | 16 ++ .../ConsoleMode.Tests/ControllerMouseTests.cs | 159 ++++++++++++ 25 files changed, 989 insertions(+), 12 deletions(-) create mode 100644 src/ConsoleMode/Services/ControllerMouse.cs create mode 100644 src/ConsoleMode/Services/ControllerMouseMath.cs create mode 100644 src/ConsoleMode/Services/CursorHider.cs create mode 100644 src/ConsoleMode/ViewModels/MainViewModel.Pointer.cs create mode 100644 tests/ConsoleMode.Tests/ControllerMouseTests.cs diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index a0dd267..1b1ea21 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -4,6 +4,9 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ## [Unreleased] +### What's new +- The mouse pointer stays out of the way: during a session it disappears after 3 seconds without moving and comes back as soon as the mouse moves (handy for emulators that leave it in the middle of the TV); turn it off in Settings → "Hide the mouse pointer while playing". And for games and emulators whose menus ignore the controller, the session menu's "Control the mouse with the controller" row makes the left stick move the pointer, the right stick scroll, A click, A twice right-click and A held drag. It lasts until the session ends. + ## [1.6.0] ### What's new - Redesigned Console interface, made to feel like a console: a top bar with three tabs (Home, Session and System) you switch with LB/RB (L1/R1 on PlayStation), screen tiles that show what will happen to each monitor, quick settings with icons and a focus that grows as you move over it. Home brings the Play now banner and a summary of the current setup (game screen, audio, launcher and HDR/VRR). (#117) diff --git a/CHANGELOG.md b/CHANGELOG.md index bf01bf8..02d6b4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,9 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ## [Unreleased] +### Novidades +- Cursor do mouse fora do caminho: durante a sessão ele some depois de 3 segundos parado e volta assim que o mouse se mexe (útil em emuladores que deixam o cursor no meio da TV); dá para desligar em Ajustes → "Ocultar o cursor do mouse durante o jogo". E, para jogos e emuladores cujos menus não aceitam o controle, a linha "Controlar o mouse com o controle" do menu da sessão faz o analógico esquerdo mover o cursor, o direito rolar, A clicar, A duas vezes clicar com o botão direito e A segurado arrastar. Vale até o fim da sessão. + ## [1.6.0] ### Novidades - Interface Console redesenhada, com cara de console: barra no topo com três abas (Início, Sessão e Sistema) que você troca com LB/RB (L1/R1 no PlayStation), blocos das telas que mostram o que vai acontecer com cada monitor, ajustes rápidos com ícones e foco que cresce ao passar. Início traz o banner com Jogar agora e um resumo do setup atual (tela de jogo, áudio, iniciador e HDR/VRR). (#117) diff --git a/README.md b/README.md index 98b474e..730f3d8 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ You pick the controller shortcuts yourself: none is on at first, the app asks th - [Turns the TV on and switches it to the PC's input](docs/GUIDE.md#tv-control) (Google TV / Android TV over the network), and can put it in standby afterwards - Launches **Steam Big Picture**, **Playnite fullscreen** or **Xbox** mode, and sends Steam to the tray when you go back to the PC - **Session menu** over the game: switch or close the open windows, change volume, resolution, audio, FPS and HDR, and browse your files with [ControlFS](https://github.com/nextestudios/ControlFS) +- **Mouse pointer out of the way:** hidden while it sits still, and driven by the controller from the session menu when a game or emulator ignores the pad - Two interfaces: **Desktop** (mouse) and **Console** (full screen, Xbox and PlayStation pads, LB/RB tabs, your Steam covers as the background) - Automation with `consolemode://start` / `stop` / `menu` links (Stream Deck, scripts) and a [local control API](docs/GUIDE.md#local-control-api) - English, Portuguese and Spanish diff --git a/README.pt-BR.md b/README.pt-BR.md index abc4a2b..3838389 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -49,6 +49,7 @@ Os atalhos do controle são você quem escolhe: nenhum vem ligado, o app pergunt - [Liga a TV e troca para a entrada do PC](docs/GUIDE.pt-BR.md#controle-da-tv) (Google TV / Android TV pela rede), e pode colocá-la em espera no fim - Abre **Steam Big Picture**, **Playnite em tela cheia** ou o **Modo Xbox**, e manda a Steam para a bandeja ao voltar ao PC - **Menu da sessão** sobre o jogo: troque ou feche as janelas abertas, mude volume, resolução, áudio, FPS e HDR e navegue pelos seus arquivos com o [ControlFS](https://github.com/nextestudios/ControlFS) +- **Cursor do mouse fora do caminho:** some quando fica parado e pode ser movido pelo controle, pelo menu da sessão, quando um jogo ou emulador não aceita o controle - Duas interfaces: **Desktop** (mouse) e **Console** (tela cheia, controles Xbox e PlayStation, abas no LB/RB, as capas da sua Steam como plano de fundo) - Automação com os links `consolemode://start` / `stop` / `menu` (Stream Deck, scripts) e uma [API de controle local](docs/GUIDE.pt-BR.md#api-de-controle-local) - Português, inglês e espanhol diff --git a/docs/TESTING.md b/docs/TESTING.md index 99f71e3..881f189 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -177,6 +177,29 @@ Pré-condição: ControlFS instalado (https://github.com/nextestudios/ControlFS) - [ ] Depois de instalado pelo menu, o ControlFS aparece em Configurações → Aplicativos instalados e nenhum `ControlFS-Setup-x64.exe` sobra em `%TEMP%ConsoleModeUpdate`. Resultado: ______ - [ ] Fora da sessão (prévia do menu) o painel funciona igual. Resultado: ______ +## 1e. Cursor do mouse: ocultar parado e controlar pelo controle + +Pré-condição: um emulador ou jogo que deixa o cursor visível (ex.: um emulador aberto pelo Playnite). + +### Ocultar o cursor +- [ ] Na sessão, deixe o mouse parado: depois de ~3 s o cursor some da TV, também por cima do emulador. Resultado: ______ +- [ ] Mexa o mouse físico: o cursor volta na hora, com o esquema de ponteiros do Windows de sempre (tamanho e cor de Configurações → Acessibilidade → Ponteiro do mouse). Resultado: ______ +- [ ] Voltar ao PC com o cursor oculto: ele reaparece normal na mesa. Resultado: ______ +- [ ] Ajustes (PC e aba Sistema da interface Console) → "Ocultar o cursor do mouse durante o jogo" desligado: o cursor não some mais na sessão. Religado durante a sessão, volta a sumir. Resultado: ______ +- [ ] Fora da sessão o cursor nunca some. Resultado: ______ +- [ ] Encerre o Console Mode à força (Gerenciador de Tarefas) com o cursor oculto: o cursor continua invisível até abrir o Console Mode de novo, que o restaura logo na abertura (log: `Cursor: restaurado após um encerramento inesperado`). Resultado: ______ + +### Controlar o mouse pelo controle (Xbox **e** PlayStation) +- [ ] Menu da sessão: a linha "Controlar o mouse com o controle" mostra Desativado e a ajuda com o botão certo do controle (A no Xbox, ✕ no PlayStation). Na prévia (fora da sessão) a linha não aparece. Resultado: ______ +- [ ] A na linha: passa a Ativado. Enquanto o menu está aberto, os analógicos e o A continuam navegando no menu, sem mexer no cursor. Resultado: ______ +- [ ] Fechar o menu (B ou "Voltar ao jogo"): o A que fechou o menu **não** vira clique. Resultado: ______ +- [ ] Analógico esquerdo move o cursor com o jogo em primeiro plano; inclinação leve = movimento fino, inclinação total atravessa a TV em ~1 s, igual em 1080p e 4K. Resultado: ______ +- [ ] Analógico direito para cima/baixo rola a página (navegador, lista do emulador); para os lados rola na horizontal onde houver. Resultado: ______ +- [ ] A uma vez = clique esquerdo (com um pequeno atraso, ~¼ s); A duas vezes rápido = clique direito (abre o menu de contexto); A segurado = arrastar (solte para largar). Resultado: ______ +- [ ] Com o DualSense/DualShock **sem** Steam Input (lido por HID) o cursor também se move e clica. Resultado: ______ +- [ ] Voltar ao PC e iniciar outra sessão: a opção começa Desativada de novo. Resultado: ______ +- [ ] Limitação esperada: o jogo também recebe o A e os analógicos. Em programas abertos como administrador o cursor não mexe (o Windows bloqueia a entrada vinda de um app comum). Resultado: ______ + ## 2. Links `consolemode://` - [ ] `start consolemode://start` no `cmd` com o app **fechado**: abre e entra no modo console. Resultado: ______ diff --git a/src/ConsoleMode/App.xaml.cs b/src/ConsoleMode/App.xaml.cs index 0ff4335..042b143 100644 --- a/src/ConsoleMode/App.xaml.cs +++ b/src/ConsoleMode/App.xaml.cs @@ -71,6 +71,7 @@ protected override async void OnLaunched(LaunchActivatedEventArgs args) try { AppPaths.Initialize(); + CursorHider.RecoverAfterCrash(); LocalizationService.SetLanguage(LocalizationService.ResolveInitial( ConfigService.Load().AppLanguage, StartupService.ReadInstallerLanguage(), CultureInfo.CurrentUICulture.Name)); ControllerInput.Warmup(); diff --git a/src/ConsoleMode/Models/Models.cs b/src/ConsoleMode/Models/Models.cs index 6fbd8de..c5f1ddd 100644 --- a/src/ConsoleMode/Models/Models.cs +++ b/src/ConsoleMode/Models/Models.cs @@ -69,6 +69,9 @@ public sealed class AppConfig /// public bool CloseSteamOnRestore { get; set; } = true; + /// During a session, hide the mouse pointer after a few seconds still (CursorHider). + public bool HideCursor { get; set; } = true; + /// Enter console mode when a controller connects while the app is in the tray. public bool AutoStartOnController { get; set; } diff --git a/src/ConsoleMode/Resources/Strings.en-US.json b/src/ConsoleMode/Resources/Strings.en-US.json index 62f6435..d715391 100644 --- a/src/ConsoleMode/Resources/Strings.en-US.json +++ b/src/ConsoleMode/Resources/Strings.en-US.json @@ -134,6 +134,10 @@ "ShortcutsOnboardingDone": "Done", "CloseSteamCard": "Send Steam to the tray when going back to the PC", "CloseSteamDescription": "When you leave Big Picture, Steam receives a normal window-close request so it can move to the tray without exiting the process. If a game is running or its state cannot be checked, the window is left unchanged.", + "HideCursorCard": "Hide the mouse pointer while playing", + "HideCursorDescription": "During a session, the pointer disappears after 3 seconds without moving and comes back as soon as the mouse moves. Handy for emulators and games that leave it in the middle of the screen.", + "ControllerMouseRow": "Control the mouse with the controller", + "ControllerMouseHint": "Left stick moves · {0} clicks · {0} twice: right click · right stick scrolls", "AutoStartControllerCard": "Enter when a controller connects", "AutoStartControllerDescription": "With the app in the tray, turning on or plugging in a controller enters console mode. Ignores the first 15 s after opening and 30 s after restoring.", "HomeButtonShortPressCard": "Short press of the Xbox button", diff --git a/src/ConsoleMode/Resources/Strings.es-ES.json b/src/ConsoleMode/Resources/Strings.es-ES.json index f76fb90..e68198b 100644 --- a/src/ConsoleMode/Resources/Strings.es-ES.json +++ b/src/ConsoleMode/Resources/Strings.es-ES.json @@ -134,6 +134,10 @@ "ShortcutsOnboardingDone": "Listo", "CloseSteamCard": "Enviar Steam a la bandeja al volver al PC", "CloseSteamDescription": "Al salir de Big Picture, Steam recibe una solicitud normal para cerrar la ventana y pasar a la bandeja sin finalizar el proceso. Si hay un juego en ejecución o no se puede comprobar su estado, la ventana no se modifica.", + "HideCursorCard": "Ocultar el cursor del ratón al jugar", + "HideCursorDescription": "Durante la sesión, el cursor desaparece tras 3 segundos quieto y vuelve en cuanto se mueve el ratón. Útil en emuladores y juegos que lo dejan en el centro de la pantalla.", + "ControllerMouseRow": "Controlar el ratón con el mando", + "ControllerMouseHint": "Stick izquierdo mueve · {0} hace clic · {0} dos veces: clic derecho · stick derecho desplaza", "AutoStartControllerCard": "Entrar al conectar un mando", "AutoStartControllerDescription": "Con la aplicación en la bandeja, encender o conectar un mando entra en modo consola. Ignora los primeros 15 s tras abrir y 30 s tras restaurar.", "HomeButtonShortPressCard": "Pulsación corta del botón Xbox", diff --git a/src/ConsoleMode/Resources/Strings.pt-BR.json b/src/ConsoleMode/Resources/Strings.pt-BR.json index 5b20c6b..43da718 100644 --- a/src/ConsoleMode/Resources/Strings.pt-BR.json +++ b/src/ConsoleMode/Resources/Strings.pt-BR.json @@ -134,6 +134,10 @@ "ShortcutsOnboardingDone": "Concluir", "CloseSteamCard": "Mandar a Steam para a bandeja ao voltar ao PC", "CloseSteamDescription": "Ao sair do Big Picture, envia o fechamento normal da janela da Steam para ela ir à bandeja, sem encerrar o processo. Se houver um jogo em execução ou não for possível verificar, a janela não é alterada.", + "HideCursorCard": "Ocultar o cursor do mouse durante o jogo", + "HideCursorDescription": "Na sessão, o cursor some depois de 3 segundos parado e volta assim que o mouse se mexe. Útil em emuladores e jogos que deixam o cursor no meio da tela.", + "ControllerMouseRow": "Controlar o mouse com o controle", + "ControllerMouseHint": "Analógico esquerdo move · {0} clica · {0} duas vezes: botão direito · analógico direito rola", "AutoStartControllerCard": "Entrar ao conectar um controle", "AutoStartControllerDescription": "Com o app na bandeja, ligar ou conectar um controle entra no modo console. Ignora os primeiros 15 s após abrir e 30 s após restaurar.", "HomeButtonShortPressCard": "Toque curto no botão Xbox", diff --git a/src/ConsoleMode/Services/ControllerMapping.cs b/src/ConsoleMode/Services/ControllerMapping.cs index d5185a7..e0533e7 100644 --- a/src/ConsoleMode/Services/ControllerMapping.cs +++ b/src/ConsoleMode/Services/ControllerMapping.cs @@ -29,16 +29,7 @@ public static class ControllerMapping /// public static ushort SonyButtons(ReadOnlySpan report, bool dualSense, int inputReportLength = 0) { - if (report.Length == 0) return 0; - // Offset of the buttons byte (hat + face) and of the left stick X, per report layout. - var (start, stick) = report[0] switch - { - 0x31 => (9, 2), // DualSense, Bluetooth - 0x11 => (7, 3), // DS4, Bluetooth - 0x01 when IsDualSenseUsbReport(report.Length, dualSense, inputReportLength) => (8, 1), // DualSense, USB - 0x01 => (5, 1), // DS4 USB, or either pad's basic Bluetooth report - _ => (-1, -1) - }; + var (start, stick) = SonyOffsets(report, dualSense, inputReportLength); if (start < 0 || report.Length < start + 3) return 0; var face = report[start]; var hat = face & 0x0F; @@ -67,6 +58,31 @@ 0x01 when IsDualSenseUsbReport(report.Length, dualSense, inputReportLength) => ( return bits; } + /// + /// Both sticks of a raw DS4/DualSense input report as bytes (0..255, 128 = center, Y grows downward): + /// left X, left Y, right X, right Y. Null for an unknown report. Read by ControllerMouse. + /// + public static (byte LeftX, byte LeftY, byte RightX, byte RightY)? SonySticks(ReadOnlySpan report, bool dualSense, int inputReportLength = 0) + { + var (_, stick) = SonyOffsets(report, dualSense, inputReportLength); + if (stick < 0 || report.Length < stick + 4) return null; + return (report[stick], report[stick + 1], report[stick + 2], report[stick + 3]); + } + + /// Offset of the buttons byte (hat + face) and of the left stick X, per report layout; (-1, -1) when unknown. + private static (int Buttons, int Stick) SonyOffsets(ReadOnlySpan report, bool dualSense, int inputReportLength) + { + if (report.Length == 0) return (-1, -1); + return report[0] switch + { + 0x31 => (9, 2), // DualSense, Bluetooth + 0x11 => (7, 3), // DS4, Bluetooth + 0x01 when IsDualSenseUsbReport(report.Length, dualSense, inputReportLength) => (8, 1), // DualSense, USB + 0x01 => (5, 1), // DS4 USB, or either pad's basic Bluetooth report + _ => (-1, -1) + }; + } + /// Report IDs reads: basic/USB (0x01), DS4 Bluetooth (0x11), DualSense Bluetooth (0x31). public static bool IsKnownSonyReport(byte reportId) => reportId is 0x01 or 0x11 or 0x31; diff --git a/src/ConsoleMode/Services/ControllerMouse.cs b/src/ConsoleMode/Services/ControllerMouse.cs new file mode 100644 index 0000000..5ae0b15 --- /dev/null +++ b/src/ConsoleMode/Services/ControllerMouse.cs @@ -0,0 +1,240 @@ +using System.Diagnostics; +using System.Runtime.InteropServices; + +namespace ConsoleMode.Services; + +/// +/// "Control the mouse" in the session menu, for games and emulators whose menus don't take a controller: +/// the left stick moves the pointer, the right stick scrolls, A clicks (twice = right click, held = drag; +/// see ClickGesture). Reads XInput and Sony HID, which both work while the game has the focus +/// (Windows.Gaming.Input only reaches the foreground window). Runs on its own thread so the pointer stays +/// smooth when the UI is busy; while the session menu is open, where the pad drives the menu. +/// +public sealed class ControllerMouse : IDisposable +{ + private const ushort XInputA = 0x1000; + private const uint MouseMove = 0x0001; + private const uint MouseLeftDown = 0x0002; + private const uint MouseLeftUp = 0x0004; + private const uint MouseRightDown = 0x0008; + private const uint MouseRightUp = 0x0010; + private const uint MouseWheel = 0x0800; + private const uint MouseHWheel = 0x1000; + private const int WheelDelta = 120; + private static readonly TimeSpan SampleInterval = TimeSpan.FromMilliseconds(8); + + private CancellationTokenSource? _cts; + private Thread? _thread; + private static bool _xinputMissing; + + public bool IsRunning => _cts is not null; + + /// The pad stops driving the mouse (an ongoing drag is released) until this is false again. + public bool Paused + { + get => _paused; + set => _paused = value; + } + + private volatile bool _paused; + + /// Height of the game screen, so the pointer speed is the same on any resolution. + public void Start(int screenHeight) + { + if (_cts is not null) return; + SonyHidReader.Acquire(); + var cts = new CancellationTokenSource(); + _cts = cts; + _thread = new Thread(() => Run(screenHeight, cts.Token)) { IsBackground = true, Name = "ControllerMouse" }; + _thread.Start(); + AppLog.Write("Mouse pelo controle: ligado"); + } + + public void Stop() + { + if (_cts is not { } cts) return; + _cts = null; + cts.Cancel(); + _thread?.Join(500); + _thread = null; + SonyHidReader.Release(); + AppLog.Write("Mouse pelo controle: desligado"); + } + + public void Dispose() => Stop(); + + private void Run(int screenHeight, CancellationToken ct) + { + var pointer = new PointerMotion(); + var wheelY = new WheelMotion(); + var wheelX = new WheelMotion(); + var gesture = new ClickGesture(); + var clock = Stopwatch.StartNew(); + var last = clock.Elapsed; + var wasPaused = true; + var leftDown = false; + try + { + while (!ct.IsCancellationRequested) + { + Thread.Sleep(SampleInterval); + var now = clock.Elapsed; + // A stall (sleep, debugger) must not throw the pointer across the screen. + var seconds = Math.Min((now - last).TotalSeconds, 0.05); + last = now; + + if (_paused) + { + if (!wasPaused) + { + if (gesture.Reset()) Send(MouseLeftUp); + leftDown = false; + pointer.Reset(); + wheelX.Reset(); + wheelY.Reset(); + } + wasPaused = true; + continue; + } + if (wasPaused) + { + // Coming back (or starting): the A that closed the menu is probably still down. + gesture.Reset(); + wasPaused = false; + } + + var pad = Read(); + var (dx, dy) = pointer.Step(pad.LeftX, pad.LeftY, seconds, screenHeight); + if (dx != 0 || dy != 0) Send(MouseMove, dx, dy); + var notchesY = wheelY.Step(pad.RightY, seconds); + if (notchesY != 0) Send(MouseWheel, data: notchesY * WheelDelta); + var notchesX = wheelX.Step(pad.RightX, seconds); + if (notchesX != 0) Send(MouseHWheel, data: notchesX * WheelDelta); + + switch (gesture.Update(pad.A, now)) + { + case ClickEvent.LeftClick: + Send(MouseLeftDown); + Send(MouseLeftUp); + break; + case ClickEvent.RightClick: + Send(MouseRightDown); + Send(MouseRightUp); + break; + case ClickEvent.LeftDown: + Send(MouseLeftDown); + leftDown = true; + break; + case ClickEvent.LeftUp: + Send(MouseLeftUp); + leftDown = false; + break; + } + } + } + catch (Exception ex) + { + AppLog.Write($"Mouse pelo controle: {ex.Message}"); + } + finally + { + // Never leave the left button stuck down when the mode ends mid-drag. + if (leftDown) Send(MouseLeftUp); + } + } + + private readonly record struct PadSample(double LeftX, double LeftY, double RightX, double RightY, bool A); + + /// Every pad at once: the stick pushed furthest wins, A counts on any pad. + private static PadSample Read() + { + double lx = 0, ly = 0, rx = 0, ry = 0; + var a = false; + + void Take(double x, double y, ref double bestX, ref double bestY) + { + if (x * x + y * y > bestX * bestX + bestY * bestY) { bestX = x; bestY = y; } + } + + if (!_xinputMissing) + { + try + { + for (uint i = 0; i < 4; i++) + { + if (XInputGetState(i, out var state) != 0) continue; + var g = state.Gamepad; + Take(ControllerMouseMath.FromXInput(g.sThumbLX), ControllerMouseMath.FromXInput(g.sThumbLY), ref lx, ref ly); + Take(ControllerMouseMath.FromXInput(g.sThumbRX), ControllerMouseMath.FromXInput(g.sThumbRY), ref rx, ref ry); + a |= (g.wButtons & XInputA) != 0; + } + } + catch (DllNotFoundException) + { + _xinputMissing = true; + } + } + + foreach (var s in SonyHidReader.ReadSticks()) + { + Take(ControllerMouseMath.FromHidByte(s.LeftX, false), ControllerMouseMath.FromHidByte(s.LeftY, true), ref lx, ref ly); + Take(ControllerMouseMath.FromHidByte(s.RightX, false), ControllerMouseMath.FromHidByte(s.RightY, true), ref rx, ref ry); + } + a |= (SonyHidReader.Held & XInputA) != 0; + return new PadSample(lx, ly, rx, ry, a); + } + + private static void Send(uint flags, int dx = 0, int dy = 0, int data = 0) + { + var input = new Input + { + Type = 0, // INPUT_MOUSE + Mouse = new MouseInput { Dx = dx, Dy = dy, MouseData = unchecked((uint)data), Flags = flags } + }; + // Fails silently against a window running as administrator (UIPI): nothing to do from here. + _ = SendInput(1, [input], Marshal.SizeOf()); + } + + [StructLayout(LayoutKind.Sequential)] + private struct MouseInput + { + public int Dx; + public int Dy; + public uint MouseData; + public uint Flags; + public uint Time; + public nint ExtraInfo; + } + + [StructLayout(LayoutKind.Sequential)] + private struct Input + { + public uint Type; + public MouseInput Mouse; + } + + [StructLayout(LayoutKind.Sequential)] + private struct XInputGamepad + { + public ushort wButtons; + public byte bLeftTrigger; + public byte bRightTrigger; + public short sThumbLX; + public short sThumbLY; + public short sThumbRX; + public short sThumbRY; + } + + [StructLayout(LayoutKind.Sequential)] + private struct XInputState + { + public uint dwPacketNumber; + public XInputGamepad Gamepad; + } + + [DllImport("xinput1_4.dll", EntryPoint = "XInputGetState")] + private static extern uint XInputGetState(uint userIndex, out XInputState state); + + [DllImport("user32.dll", SetLastError = true)] + private static extern uint SendInput(uint count, Input[] inputs, int size); +} diff --git a/src/ConsoleMode/Services/ControllerMouseMath.cs b/src/ConsoleMode/Services/ControllerMouseMath.cs new file mode 100644 index 0000000..bcf5e70 --- /dev/null +++ b/src/ConsoleMode/Services/ControllerMouseMath.cs @@ -0,0 +1,197 @@ +namespace ConsoleMode.Services; + +/// Pure rules behind ControllerMouse and CursorHider, kept apart so the tests cover them. +public static class ControllerMouseMath +{ + /// Radial dead zone of the left stick (pointer); worn sticks rest a little off center. + public const double MoveDeadZone = 0.18; + /// The right stick (wheel) needs a clearer push, so aiming with the left one never scrolls by accident. + public const double ScrollDeadZone = 0.3; + /// Full tilt crosses about this many screen heights per second, so a 4K TV feels like a 1080p one. + public const double ScreensPerSecond = 1.1; + public const double MinNotchesPerSecond = 3; + public const double MaxNotchesPerSecond = 16; + /// The pointer is hidden after sitting still this long. + public static readonly TimeSpan CursorIdleDelay = TimeSpan.FromSeconds(3); + + /// XInput stick value (-32768..32767, Y up) to -1..1. + public static double FromXInput(short value) => Math.Max(-1, value / 32767.0); + + /// Sony HID stick byte (0..255, 128 = center, Y down) to -1..1 with Y up like XInput. + public static double FromHidByte(byte value, bool invert) => Math.Clamp((value - 128) / 127.0, -1, 1) * (invert ? -1 : 1); + + /// + /// Radial dead zone, then a squared curve: small tilts aim precisely, full tilt travels. + /// Input and output in -1..1; the direction is kept. + /// + public static (double X, double Y) Curve(double x, double y, double deadZone) + { + var magnitude = Math.Sqrt(x * x + y * y); + if (magnitude <= deadZone) return (0, 0); + var scaled = Math.Min(1, (magnitude - deadZone) / (1 - deadZone)); + var factor = scaled * scaled / magnitude; + return (x * factor, y * factor); + } + + /// A pointer that has not moved for is hidden; CursorHider shows it again on the next move. + public static bool ShouldHideCursor(TimeSpan stillFor) => stillFor >= CursorIdleDelay; +} + +/// Left stick to pointer pixels, carrying the fractions between samples so slow moves don't stall. +public sealed class PointerMotion +{ + private double _restX; + private double _restY; + + /// Stick X, -1..1. + /// Stick Y, -1..1, up positive (the screen's Y grows downward). + /// Time since the previous sample. + /// Height of the game screen in pixels. + public (int Dx, int Dy) Step(double x, double y, double seconds, int screenHeight) + { + var (vx, vy) = ControllerMouseMath.Curve(x, y, ControllerMouseMath.MoveDeadZone); + if (vx == 0 && vy == 0) + { + Reset(); + return (0, 0); + } + var speed = Math.Max(screenHeight, 720) * ControllerMouseMath.ScreensPerSecond; + _restX += vx * speed * seconds; + _restY -= vy * speed * seconds; + var dx = (int)Math.Truncate(_restX); + var dy = (int)Math.Truncate(_restY); + _restX -= dx; + _restY -= dy; + return (dx, dy); + } + + public void Reset() => _restX = _restY = 0; +} + +/// +/// One right-stick axis to whole wheel notches (positive = up / right). Whole notches only, since some +/// programs ignore partial ones; the first notch goes out as soon as the stick leaves the dead zone. +/// +public sealed class WheelMotion +{ + private double _pending; + private bool _moving; + + public int Step(double axis, double seconds) + { + var magnitude = Math.Abs(axis); + if (magnitude <= ControllerMouseMath.ScrollDeadZone) + { + Reset(); + return 0; + } + var scaled = Math.Min(1, (magnitude - ControllerMouseMath.ScrollDeadZone) / (1 - ControllerMouseMath.ScrollDeadZone)); + var rate = ControllerMouseMath.MinNotchesPerSecond + + (ControllerMouseMath.MaxNotchesPerSecond - ControllerMouseMath.MinNotchesPerSecond) * scaled * scaled; + if (!_moving) + { + _moving = true; + _pending = 1; + } + else + { + _pending += rate * seconds; + } + var notches = (int)Math.Truncate(_pending); + _pending -= notches; + return notches * Math.Sign(axis); + } + + public void Reset() + { + _pending = 0; + _moving = false; + } +} + +public enum ClickEvent +{ + None, + /// A tapped once: left down and up. + LeftClick, + /// A tapped twice: right down and up. + RightClick, + /// A held: left button down, to drag. + LeftDown, + /// A released after a hold. + LeftUp +} + +/// +/// The A button as a mouse: a tap is a left click, two quick taps a right click, a hold a left drag. +/// The single click waits after the release, to know it isn't the first +/// half of a right click. +/// +public sealed class ClickGesture +{ + public static readonly TimeSpan HoldForDrag = TimeSpan.FromMilliseconds(350); + public static readonly TimeSpan DoubleTapWindow = TimeSpan.FromMilliseconds(250); + + private enum Phase { WaitRelease, Idle, FirstPress, WaitSecondTap, Dragging } + + private Phase _phase = Phase.WaitRelease; + private TimeSpan _since; + + /// One sample of the A button at (any monotonic clock). + public ClickEvent Update(bool held, TimeSpan now) + { + switch (_phase) + { + case Phase.WaitRelease: + if (!held) _phase = Phase.Idle; + return ClickEvent.None; + + case Phase.Idle: + if (held) Enter(Phase.FirstPress, now); + return ClickEvent.None; + + case Phase.FirstPress: + if (!held) + { + Enter(Phase.WaitSecondTap, now); + return ClickEvent.None; + } + if (now - _since < HoldForDrag) return ClickEvent.None; + _phase = Phase.Dragging; + return ClickEvent.LeftDown; + + case Phase.WaitSecondTap: + if (held) + { + _phase = Phase.WaitRelease; + return ClickEvent.RightClick; + } + if (now - _since < DoubleTapWindow) return ClickEvent.None; + _phase = Phase.Idle; + return ClickEvent.LeftClick; + + case Phase.Dragging: + if (held) return ClickEvent.None; + _phase = Phase.Idle; + return ClickEvent.LeftUp; + } + return ClickEvent.None; + } + + /// + /// Starts over, ignoring A until it is released (the press that closed the session menu must not click). + /// True when the left button is still down from a drag and must be released by the caller. + /// + public bool Reset() + { + var wasDragging = _phase == Phase.Dragging; + _phase = Phase.WaitRelease; + return wasDragging; + } + + private void Enter(Phase phase, TimeSpan now) + { + _phase = phase; + _since = now; + } +} diff --git a/src/ConsoleMode/Services/CursorHider.cs b/src/ConsoleMode/Services/CursorHider.cs new file mode 100644 index 0000000..56467fe --- /dev/null +++ b/src/ConsoleMode/Services/CursorHider.cs @@ -0,0 +1,194 @@ +using System.Runtime.InteropServices; +using Microsoft.UI.Dispatching; + +namespace ConsoleMode.Services; + +/// +/// Hides the mouse pointer during a session once it sits still for a few seconds (emulators and some games +/// leave it in the middle of the TV) and shows it again the moment it moves, by hand or by ControllerMouse. +/// Windows has no "hide the pointer for every app" call, so the system cursors are swapped for a blank one +/// (SetSystemCursor) and reloaded from the user's scheme afterwards (SPI_SETCURSORS). A marker file covers +/// a crash in between: the next start puts the cursors back (). +/// +public sealed class CursorHider : IDisposable +{ + // OCR_NORMAL, IBEAM, WAIT, CROSS, UP, SIZENWSE, SIZENESW, SIZEWE, SIZENS, SIZEALL, NO, HAND, APPSTARTING, HELP, PIN, PERSON. + private static readonly uint[] SystemCursors = + [32512, 32513, 32514, 32515, 32516, 32642, 32643, 32644, 32645, 32646, 32648, 32649, 32650, 32651, 32671, 32672]; + private const uint SpiSetCursors = 0x0057; + private const int SmCxCursor = 13; + private const int SmCyCursor = 14; + + private static readonly object Gate = new(); + private static bool _hidden; + private static bool _exitHooked; + + private readonly DispatcherQueueTimer _timer; + private Point _lastPosition; + private DateTime _lastMove; + + public CursorHider(DispatcherQueue dispatcher) + { + _timer = dispatcher.CreateTimer(); + _timer.Interval = TimeSpan.FromMilliseconds(150); + _timer.Tick += (_, _) => Poll(); + } + + public bool IsRunning => _timer.IsRunning; + + public void Start() + { + if (_timer.IsRunning) return; + lock (Gate) + { + if (!_exitHooked) + { + _exitHooked = true; + AppDomain.CurrentDomain.ProcessExit += (_, _) => Show(); + } + } + GetCursorPos(out _lastPosition); + _lastMove = DateTime.UtcNow; + _timer.Start(); + AppLog.Write("Cursor: ocultar quando parado ligado"); + } + + public void Stop() + { + if (!_timer.IsRunning) return; + _timer.Stop(); + Show(); + } + + public void Dispose() => Stop(); + + private void Poll() + { + var now = DateTime.UtcNow; + // On the secure desktop (UAC) the position can't be read: count it as still. + var moved = GetCursorPos(out var position) && (position.X != _lastPosition.X || position.Y != _lastPosition.Y); + if (moved) + { + _lastPosition = position; + _lastMove = now; + Show(); + return; + } + if (ControllerMouseMath.ShouldHideCursor(now - _lastMove)) Hide(); + } + + private static void Hide() + { + lock (Gate) + { + if (_hidden) return; + _hidden = true; + WriteMarker(true); + var blank = CreateBlankCursor(); + if (blank == 0) + { + AppLog.Write($"Cursor: não foi possível criar o cursor vazio ({Marshal.GetLastWin32Error()})"); + return; + } + // SetSystemCursor takes ownership of each handle, so every slot gets its own copy. + foreach (var id in SystemCursors) SetSystemCursor(CopyIcon(blank), id); + DestroyCursor(blank); + } + } + + private static void Show() + { + lock (Gate) + { + if (!_hidden) return; + _hidden = false; + Restore(); + } + } + + /// At startup: a previous run that died with the pointer hidden left the marker behind. + public static void RecoverAfterCrash() + { + try + { + if (!File.Exists(MarkerPath)) return; + Restore(); + AppLog.Write("Cursor: restaurado após um encerramento inesperado"); + } + catch (Exception ex) + { + AppLog.Write($"Cursor: restaurar: {ex.Message}"); + } + } + + private static void Restore() + { + // Reloads the user's own scheme (Settings → Mouse pointer), whatever it is. + if (!SystemParametersInfo(SpiSetCursors, 0, 0, 0)) + AppLog.Write($"Cursor: não foi possível restaurar os cursores ({Marshal.GetLastWin32Error()})"); + WriteMarker(false); + } + + private static string MarkerPath => Path.Combine(AppPaths.DataDir, "cursor-hidden"); + + private static void WriteMarker(bool hidden) + { + try + { + if (string.IsNullOrEmpty(AppPaths.DataDir)) return; + if (hidden) File.WriteAllText(MarkerPath, ""); + else if (File.Exists(MarkerPath)) File.Delete(MarkerPath); + } + catch (Exception ex) + { + AppLog.Write($"Cursor: marcador: {ex.Message}"); + } + } + + /// A cursor of the system size whose AND mask keeps the screen and whose XOR mask adds nothing. + private static nint CreateBlankCursor() + { + var width = Math.Max(GetSystemMetrics(SmCxCursor), 32); + var height = Math.Max(GetSystemMetrics(SmCyCursor), 32); + var size = (width + 15) / 16 * 2 * height; // rows are WORD-aligned + var and = new byte[size]; + Array.Fill(and, (byte)0xFF); + var xor = new byte[size]; + return CreateCursor(GetModuleHandle(null), 0, 0, width, height, and, xor); + } + + [StructLayout(LayoutKind.Sequential)] + private struct Point + { + public int X; + public int Y; + } + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool GetCursorPos(out Point point); + + [DllImport("user32.dll", SetLastError = true)] + private static extern nint CreateCursor(nint instance, int hotX, int hotY, int width, int height, byte[] andPlane, byte[] xorPlane); + + [DllImport("user32.dll")] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool DestroyCursor(nint cursor); + + [DllImport("user32.dll")] + private static extern nint CopyIcon(nint icon); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SetSystemCursor(nint cursor, uint id); + + [DllImport("user32.dll", SetLastError = true)] + [return: MarshalAs(UnmanagedType.Bool)] + private static extern bool SystemParametersInfo(uint action, uint param, nint value, uint winIni); + + [DllImport("user32.dll")] + private static extern int GetSystemMetrics(int index); + + [DllImport("kernel32.dll", CharSet = CharSet.Unicode)] + private static extern nint GetModuleHandle(string? moduleName); +} diff --git a/src/ConsoleMode/Services/LocalizationService.cs b/src/ConsoleMode/Services/LocalizationService.cs index e7e75a3..a539bca 100644 --- a/src/ConsoleMode/Services/LocalizationService.cs +++ b/src/ConsoleMode/Services/LocalizationService.cs @@ -227,6 +227,9 @@ internal void NotifyAll() public string HomeButtonShortPressCard => LocalizationService.Get(nameof(HomeButtonShortPressCard)); public string CloseSteamCard => LocalizationService.Get(nameof(CloseSteamCard)); public string CloseSteamDescription => LocalizationService.Get(nameof(CloseSteamDescription)); + public string HideCursorCard => LocalizationService.Get(nameof(HideCursorCard)); + public string HideCursorDescription => LocalizationService.Get(nameof(HideCursorDescription)); + public string ControllerMouseRow => LocalizationService.Get(nameof(ControllerMouseRow)); public string AutoStartControllerCard => LocalizationService.Get(nameof(AutoStartControllerCard)); public string AutoStartControllerDescription => LocalizationService.Get(nameof(AutoStartControllerDescription)); public string NotifyUpdatesCard => LocalizationService.Get(nameof(NotifyUpdatesCard)); diff --git a/src/ConsoleMode/Services/SonyHidReader.cs b/src/ConsoleMode/Services/SonyHidReader.cs index 2afa4c7..624b5df 100644 --- a/src/ConsoleMode/Services/SonyHidReader.cs +++ b/src/ConsoleMode/Services/SonyHidReader.cs @@ -13,6 +13,7 @@ public static class SonyHidReader { private static readonly object Gate = new(); private static readonly Dictionary Buttons = new(StringComparer.OrdinalIgnoreCase); + private static readonly Dictionary Sticks = new(StringComparer.OrdinalIgnoreCase); private static CancellationTokenSource? _cts; private static int _users; @@ -36,6 +37,12 @@ public static IReadOnlyDictionary ReadStates() lock (Gate) return new Dictionary(Buttons, StringComparer.OrdinalIgnoreCase); } + /// Both sticks of each Sony pad as raw bytes (see ControllerMapping.SonySticks), for ControllerMouse. + public static IReadOnlyList<(byte LeftX, byte LeftY, byte RightX, byte RightY)> ReadSticks() + { + lock (Gate) return [.. Sticks.Values]; + } + public static void Acquire() { lock (Gate) @@ -55,6 +62,7 @@ public static void Release() _cts?.Cancel(); _cts = null; Buttons.Clear(); + Sticks.Clear(); } } @@ -75,7 +83,7 @@ private static async Task ScanLoopAsync(CancellationToken ct) _ = Task.Run(async () => { await ReadLoopAsync(path, pad, ct); - lock (Gate) { Buttons.Remove(path); } + lock (Gate) { Buttons.Remove(path); Sticks.Remove(path); } lock (reading) { reading.Remove(path); } }); } @@ -114,7 +122,12 @@ private static async Task ReadLoopAsync(string path, Pad pad, CancellationToken AppLog.Write($"Controle: HID: relatório 0x{buffer[0]:X2} não reconhecido; os botões desse formato não são lidos"); } var bits = ControllerMapping.SonyButtons(buffer.AsSpan(0, read), pad.DualSense, pad.ReportLength); - lock (Gate) { Buttons[path] = bits; } + var sticks = ControllerMapping.SonySticks(buffer.AsSpan(0, read), pad.DualSense, pad.ReportLength); + lock (Gate) + { + Buttons[path] = bits; + if (sticks is { } s) Sticks[path] = s; + } } } catch (Exception ex) when (ex is IOException or OperationCanceledException or ObjectDisposedException) diff --git a/src/ConsoleMode/SessionMenuWindow.xaml b/src/ConsoleMode/SessionMenuWindow.xaml index aefd5a1..21daf0a 100644 --- a/src/ConsoleMode/SessionMenuWindow.xaml +++ b/src/ConsoleMode/SessionMenuWindow.xaml @@ -260,6 +260,21 @@ + + + + - + + + -