diff --git a/CHANGELOG.en-US.md b/CHANGELOG.en-US.md index 20fca98..6a488fe 100644 --- a/CHANGELOG.en-US.md +++ b/CHANGELOG.en-US.md @@ -6,6 +6,7 @@ English (US) release notes, mirroring CHANGELOG.md (Brazilian Portuguese). Befor ### 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) +- The mouse pointer stays out of the way: the session menu's "Hide the mouse pointer" row hides it (handy for emulators that leave it in the middle of the TV) until you turn it off or go back to the PC; nothing happens by itself. 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. (#145) ### Changes - The session menu no longer shows the "Record the last 30 s · coming soon" row, which never worked. (#147) diff --git a/CHANGELOG.md b/CHANGELOG.md index 0e14684..45605bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Notas em português do Brasil; a versão em inglês (Estados Unidos) fica em `CH ### 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) +- Cursor do mouse fora do caminho: a linha "Ocultar o cursor do mouse" do menu da sessão esconde o cursor (útil em emuladores que deixam o cursor no meio da TV) até você desligá-la ou voltar ao PC; nada acontece sozinho. 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. (#145) ### Mudanças - O menu da sessão não mostra mais a linha "Gravar os últimos 30 s · em breve", que nunca funcionou. (#147) diff --git a/README.md b/README.md index 4c79e8f..9b13567 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 4b28aea..7f2d0bf 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 7fa9aed..47e0b1c 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -195,6 +195,31 @@ 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 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 +- [ ] Menu da sessão: a linha "Ocultar o cursor do mouse" começa em Desativado, com a explicação embaixo inteira (sem cortar). Na prévia (fora da sessão) a linha não aparece. Resultado: ______ +- [ ] A na linha: Ativado, e o cursor some da TV, também por cima do emulador. Resultado: ______ +- [ ] Com ele oculto, deixe parado e depois mexa o mouse físico: o cursor **não** volta sozinho. Resultado: ______ +- [ ] A na linha de novo: Desativado, e o cursor volta com o esquema de ponteiros do Windows de sempre (tamanho e cor de Configurações → Acessibilidade → Ponteiro do mouse). Resultado: ______ +- [ ] Com o cursor oculto, ligar "Controlar o mouse com o controle" desliga "Ocultar o cursor" (o cursor aparece); ligar "Ocultar o cursor" desliga o controle do mouse. Resultado: ______ +- [ ] Voltar ao PC com o cursor oculto: ele reaparece normal na mesa, e na próxima sessão a linha começa em Desativado. Resultado: ______ +- [ ] Ajustes (PC e aba Sistema da interface Console) não têm mais a opção de ocultar o cursor. 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/Resources/Strings.en-US.json b/src/ConsoleMode/Resources/Strings.en-US.json index 711c8ce..55e7c9d 100644 --- a/src/ConsoleMode/Resources/Strings.en-US.json +++ b/src/ConsoleMode/Resources/Strings.en-US.json @@ -132,6 +132,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", + "HideCursorDescription": "Stays hidden until you turn this off or go back to the PC. 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 2dfef48..d497471 100644 --- a/src/ConsoleMode/Resources/Strings.es-ES.json +++ b/src/ConsoleMode/Resources/Strings.es-ES.json @@ -132,6 +132,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", + "HideCursorDescription": "Queda oculto hasta que lo desactives aquí o vuelvas al PC. Ú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 42b97de..08e7d0a 100644 --- a/src/ConsoleMode/Resources/Strings.pt-BR.json +++ b/src/ConsoleMode/Resources/Strings.pt-BR.json @@ -132,6 +132,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", + "HideCursorDescription": "Fica oculto até você desligar aqui ou voltar ao PC. Ú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..4024943 --- /dev/null +++ b/src/ConsoleMode/Services/ControllerMouseMath.cs @@ -0,0 +1,193 @@ +namespace ConsoleMode.Services; + +/// Pure rules behind ControllerMouse, 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; + + /// 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); + } + +} + +/// 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..23664ee --- /dev/null +++ b/src/ConsoleMode/Services/CursorHider.cs @@ -0,0 +1,140 @@ +using System.Runtime.InteropServices; + +namespace ConsoleMode.Services; + +/// +/// Hides the mouse pointer during a session when the user asks for it in the session menu (emulators and +/// some games leave it in the middle of the TV), and shows it again when they turn it off or the session +/// ends. Nothing automatic: moving the mouse doesn't bring it back. +/// 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 static class CursorHider +{ + // 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; + + public static void SetHidden(bool hidden) + { + if (hidden) Hide(); + else Show(); + } + + private static void Hide() + { + lock (Gate) + { + if (_hidden) return; + if (!_exitHooked) + { + _exitHooked = true; + AppDomain.CurrentDomain.ProcessExit += (_, _) => Show(); + } + _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); + } + + [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 49aacd3..02401f6 100644 --- a/src/ConsoleMode/Services/LocalizationService.cs +++ b/src/ConsoleMode/Services/LocalizationService.cs @@ -239,6 +239,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 6dfce27..742a635 100644 --- a/src/ConsoleMode/SessionMenuWindow.xaml +++ b/src/ConsoleMode/SessionMenuWindow.xaml @@ -268,6 +268,37 @@ + + + + + + + diff --git a/src/ConsoleMode/ViewModels/MainViewModel.Pointer.cs b/src/ConsoleMode/ViewModels/MainViewModel.Pointer.cs new file mode 100644 index 0000000..54636ad --- /dev/null +++ b/src/ConsoleMode/ViewModels/MainViewModel.Pointer.cs @@ -0,0 +1,67 @@ +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using ConsoleMode.Services; + +namespace ConsoleMode.ViewModels; + +// The mouse pointer in a session, both from the session menu and per session (off when it ends): +// "Hide the mouse pointer" hides it until turned off, nothing automatic; "Control the mouse" lets +// the controller drive it. One excludes the other: an invisible pointer can't be steered. +public partial class MainViewModel +{ + private readonly ControllerMouse _controllerMouse = new(); + + /// Session menu: the pointer is hidden until this is turned off or the session ends. Never saved. + [ObservableProperty] private bool _isCursorHidden; + + /// Session menu: the sticks and A drive the mouse. Never saved; every session starts with it off. + [ObservableProperty] private bool _isControllerMouseOn; + + /// The help line under the menu row, with the confirm button of the pad in use (A or ✕). + public string ControllerMouseHint => LocalizationService.Get("ControllerMouseHint", HintConfirm); + + partial void OnIsCursorHiddenChanged(bool value) + { + if (value) IsControllerMouseOn = false; + UpdateSessionPointer(); + } + + partial void OnIsControllerMouseOnChanged(bool value) + { + if (value) IsCursorHidden = false; + UpdateSessionPointer(); + } + + partial void OnIsSessionMenuOpenChanged(bool value) + { + if (value) OnPropertyChanged(nameof(ControllerMouseHint)); + UpdateSessionPointer(); + } + + [RelayCommand] + private void ToggleCursorHidden() => IsCursorHidden = !IsCursorHidden; + + [RelayCommand] + private void ToggleControllerMouse() => IsControllerMouseOn = !IsControllerMouseOn; + + /// Brings the pointer and the controller mouse in line with the session, the menu and the choices. + private void UpdateSessionPointer() + { + if (!IsConsoleActive && (IsControllerMouseOn || IsCursorHidden)) + { + // The session ended: both go back to off (re-enters here). + IsControllerMouseOn = false; + IsCursorHidden = false; + return; + } + + CursorHider.SetHidden(IsConsoleActive && IsCursorHidden); + + // While the menu is open the pad drives the menu, not the mouse. + _controllerMouse.Paused = IsSessionMenuOpen; + if (IsConsoleActive && IsControllerMouseOn) + _controllerMouse.Start(Engine.State.FocusMonitorRect?.Height ?? 1080); + else + _controllerMouse.Stop(); + } +} diff --git a/src/ConsoleMode/ViewModels/MainViewModel.cs b/src/ConsoleMode/ViewModels/MainViewModel.cs index 64c7fc0..8998754 100644 --- a/src/ConsoleMode/ViewModels/MainViewModel.cs +++ b/src/ConsoleMode/ViewModels/MainViewModel.cs @@ -96,6 +96,7 @@ partial void OnIsConsoleActiveChanged(bool value) { OnPropertyChanged(nameof(IsIdle)); NotifyStartState(); + UpdateSessionPointer(); if (value) _ = ShowSessionHintAsync(); } partial void OnFocusRowChanged(MonitorRowViewModel? value) diff --git a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj index 8f607bf..28cad16 100644 --- a/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj +++ b/tests/ConsoleMode.Tests/ConsoleMode.Tests.csproj @@ -31,6 +31,7 @@ + diff --git a/tests/ConsoleMode.Tests/ControllerMappingTests.cs b/tests/ConsoleMode.Tests/ControllerMappingTests.cs index 248da6f..b36efec 100644 --- a/tests/ConsoleMode.Tests/ControllerMappingTests.cs +++ b/tests/ConsoleMode.Tests/ControllerMappingTests.cs @@ -116,6 +116,22 @@ public void Sony_left_stick_maps_to_dpad(int x, int y, int expected) Assert.Equal(expected, ControllerMapping.SonyButtons(report, dualSense: true)); } + [Theory] + [MemberData(nameof(SonyLayouts))] + public void Sony_sticks_are_read_from_each_layout(int id, int length, bool dualSense, int start, int stick) + { + var report = NeutralReport(id, length, start, stick); + report[stick] = 10; + report[stick + 1] = 20; + report[stick + 2] = 30; + report[stick + 3] = 40; + Assert.Equal(((byte)10, (byte)20, (byte)30, (byte)40), ControllerMapping.SonySticks(report, dualSense, length)); + } + + [Fact] + public void Sony_sticks_of_an_unknown_report_are_null() => + Assert.Null(ControllerMapping.SonySticks(new byte[] { 0x05, 1, 2, 3, 4, 5 }, dualSense: false)); + [Fact] public void Sony_options_and_ps_map_to_start_and_guide() { diff --git a/tests/ConsoleMode.Tests/ControllerMouseTests.cs b/tests/ConsoleMode.Tests/ControllerMouseTests.cs new file mode 100644 index 0000000..fbe5d7c --- /dev/null +++ b/tests/ConsoleMode.Tests/ControllerMouseTests.cs @@ -0,0 +1,152 @@ +using ConsoleMode.Services; + +namespace ConsoleMode.Tests; + +public class ControllerMouseTests +{ + private static TimeSpan Ms(int value) => TimeSpan.FromMilliseconds(value); + + [Theory] + [InlineData(0.0, 0.0)] + [InlineData(0.1, 0.1)] // a worn stick resting off center + [InlineData(0.17, 0.0)] + public void Stick_inside_the_dead_zone_does_not_move(double x, double y) => + Assert.Equal((0.0, 0.0), ControllerMouseMath.Curve(x, y, ControllerMouseMath.MoveDeadZone)); + + [Fact] + public void Curve_keeps_the_direction_and_reaches_full_speed() + { + var (x, y) = ControllerMouseMath.Curve(0, -1, ControllerMouseMath.MoveDeadZone); + Assert.Equal(0, x, 6); + Assert.Equal(-1, y, 6); + var (dx, dy) = ControllerMouseMath.Curve(0.5, 0.5, ControllerMouseMath.MoveDeadZone); + Assert.Equal(dx, dy, 6); + } + + [Fact] + public void Half_tilt_is_much_slower_than_full_tilt() + { + var (half, _) = ControllerMouseMath.Curve(0.5, 0, ControllerMouseMath.MoveDeadZone); + var (full, _) = ControllerMouseMath.Curve(1, 0, ControllerMouseMath.MoveDeadZone); + Assert.True(half < full / 3, $"half={half} full={full}"); + } + + [Theory] + [InlineData(32767, 1.0)] + [InlineData(-32768, -1.0)] + [InlineData(0, 0.0)] + public void XInput_sticks_are_normalized(short value, double expected) => + Assert.Equal(expected, ControllerMouseMath.FromXInput(value), 3); + + [Theory] + [InlineData(128, false, 0.0)] + [InlineData(255, false, 1.0)] + [InlineData(0, false, -1.0)] + [InlineData(0, true, 1.0)] // Sony Y grows downward: pushed up reads 0 + public void Hid_sticks_are_normalized(byte value, bool invert, double expected) => + Assert.Equal(expected, ControllerMouseMath.FromHidByte(value, invert), 2); + + [Fact] + public void Pointer_moves_right_and_up_on_screen() + { + var motion = new PointerMotion(); + var (dx, dy) = motion.Step(1, 1, 0.1, 1080); + Assert.True(dx > 0); + Assert.True(dy < 0, "stick up must move the pointer up (screen Y decreases)"); + } + + [Fact] + public void Pointer_speed_follows_the_screen_height() + { + var hd = new PointerMotion().Step(1, 0, 0.1, 1080).Dx; + var uhd = new PointerMotion().Step(1, 0, 0.1, 2160).Dx; + Assert.InRange(uhd, hd * 2 - 1, hd * 2 + 1); + } + + [Fact] + public void Slow_moves_accumulate_instead_of_stalling() + { + var motion = new PointerMotion(); + var total = 0; + for (var i = 0; i < 100; i++) total += motion.Step(0.25, 0, 0.008, 1080).Dx; + Assert.True(total > 0, "a slight tilt must still move the pointer"); + Assert.Equal(0, new PointerMotion().Step(0.25, 0, 0.008, 1080).Dx); // less than a pixel in one sample + } + + [Fact] + public void Wheel_sends_a_notch_at_once_then_by_rate() + { + var wheel = new WheelMotion(); + Assert.Equal(1, wheel.Step(0.6, 0.008)); + var total = 0; + for (var i = 0; i < 125; i++) total += wheel.Step(1, 0.008); // one second at full tilt + Assert.InRange(total, ControllerMouseMath.MaxNotchesPerSecond - 1, ControllerMouseMath.MaxNotchesPerSecond + 1); + } + + [Fact] + public void Wheel_down_is_negative_and_the_dead_zone_resets_it() + { + var wheel = new WheelMotion(); + Assert.Equal(-1, wheel.Step(-0.9, 0.008)); + Assert.Equal(0, wheel.Step(0.1, 0.008)); + Assert.Equal(1, wheel.Step(0.9, 0.008)); // back out of the dead zone: a fresh first notch + } + + /// Feeds A samples (time in ms, held) and collects the events. + private static List Run(ClickGesture gesture, params (int Ms, bool Held)[] samples) => + samples.Select(s => gesture.Update(s.Held, Ms(s.Ms))).Where(e => e != ClickEvent.None).ToList(); + + private static ClickGesture Ready() + { + var gesture = new ClickGesture(); + gesture.Update(false, TimeSpan.Zero); + return gesture; + } + + [Fact] + public void A_tap_is_a_left_click_once_the_double_tap_window_passes() + { + var events = Run(Ready(), (10, true), (80, false), (200, false), (340, false)); + Assert.Equal([ClickEvent.LeftClick], events); + } + + [Fact] + public void A_tap_does_not_click_before_the_window_ends() + { + var events = Run(Ready(), (10, true), (80, false), (200, false)); + Assert.Empty(events); + } + + [Fact] + public void Two_quick_taps_are_one_right_click() + { + var events = Run(Ready(), (10, true), (80, false), (180, true), (250, false), (700, false)); + Assert.Equal([ClickEvent.RightClick], events); + } + + [Fact] + public void Holding_a_drags_with_the_left_button() + { + var events = Run(Ready(), (10, true), (200, true), (370, true), (900, true), (950, false), (1500, false)); + Assert.Equal([ClickEvent.LeftDown, ClickEvent.LeftUp], events); + } + + [Fact] + public void A_held_when_the_mode_starts_is_ignored_until_released() + { + var gesture = new ClickGesture(); + var events = Run(gesture, (0, true), (500, true), (600, false), (1200, false)); + Assert.Empty(events); + Assert.Equal([ClickEvent.LeftClick], Run(gesture, (1300, true), (1350, false), (1700, false))); + } + + [Fact] + public void Reset_mid_drag_asks_for_the_left_button_to_be_released() + { + var gesture = Ready(); + Run(gesture, (10, true), (400, true)); + Assert.True(gesture.Reset()); + Assert.False(gesture.Reset()); + Assert.Empty(Run(gesture, (500, true), (900, true))); // still waiting for A to go up + } +}