diff --git a/.claude/settings.local.json b/.claude/settings.local.json index b55e75b..4f91c7f 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -1,7 +1,8 @@ { "permissions": { "allow": [ - "Bash(go build:*)" + "Bash(go build:*)", + "Bash(git:*)" ] } } diff --git a/CLAUDE.md b/CLAUDE.md index 089e200..413f2e8 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,4 +1,8 @@ -# CLAUDE.md — KVMote (Go/Wails) +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +# KVMote (Go/Wails) Reescrita em Go do KVMote (original em C#/WinForms em `C:\vscode\KVMote`). @@ -30,16 +34,21 @@ Hardware suportado: ESP32-S3 (BLE NUS) e Arduino Leonardo + HC-06 (Serial SPP). ``` main.go — entry point Wails, bind App -app.go — App struct, métodos expostos ao frontend +app.go — App struct, métodos expostos ao frontend; implementa WindowManager internal/ transport/ transport.go — interface Transport (Detect/Connect/Send/SendLossy...) ble_windows.go — BLE NUS via tinygo/bluetooth kvm/ engine.go — lógica KVM: mouse, teclado, clipboard, modo cliente + WindowManager interface (SetMiniMode/RestoreNormalMode) + LogDebug → kvmote_debug.log (arquivo na raiz) input/ input.go — interface InputHandler + tipos (Point, MouseEvent, KeyboardEvent) input_windows.go — hooks Win32, SetCursorPos, ShowCursor, GetSystemMetrics + overlay_windows.go — janela Win32 nativa independente do Wails (neon "KVMote") + aparece no canto durante modo cliente; usa sua própria goroutine + com LockOSThread + message pump próprio frontend/ dist/index.html — UI wails.json — config Wails @@ -120,9 +129,17 @@ Saída: `build/bin/kvmote.exe` --- +## Mini-mode / Overlay + +Ao entrar em modo cliente, `engine` chama `WindowManager.SetMiniMode()` → `App` emite evento `window-mode:mini` ao frontend e exibe `OverlayWindow` nativo Win32. `OverlayWindow` tem goroutine própria com `LockOSThread` e message pump independente do Wails — não interfere com WebView2, Raw Input, nem coordenadas Wails. + +--- + ## ⚠️ Cuidados - **Scroll touchpad:** `scrollActive` e `scrollTimer` não devem ser resetados em enter/exitClientMode -- **runtime.LockOSThread:** nunca remover da goroutine de hooks +- **runtime.LockOSThread:** nunca remover da goroutine de hooks nem da goroutine do overlay - **isWarping flag:** previne loop infinito SetCursorPos → WM_MOUSEMOVE → SetCursorPos - **Mutex ordering:** engine.mu protege todo estado KVM; transport.mu protege conexão BLE +- **overlayOnce:** `RegisterClassExW` só pode ser chamado uma vez por processo; overlay usa `sync.Once` +- **LogDebug:** escreve em `kvmote_debug.log` na raiz do projeto (ignorado pelo git) diff --git a/app.go b/app.go index 37599f0..148f5f7 100644 --- a/app.go +++ b/app.go @@ -14,13 +14,10 @@ import ( // App struct type App struct { - ctx context.Context - engine *kvm.Engine - - // Estado da janela (pixels físicos Win32) - origX, origY int32 - origW, origH int32 - isMini bool + ctx context.Context + engine *kvm.Engine + overlay *input.OverlayWindow + isMini bool } // NewApp creates a new App application struct @@ -29,36 +26,23 @@ func NewApp() *App { h := input.NewInputHandler() e := kvm.NewEngine(t, h) app := &App{ - engine: e, + engine: e, + overlay: input.NewOverlayWindow(), } - e.SetWindowManager(app) // Vincula o app como gerenciador de janela da engine + e.SetWindowManager(app) return app } -const miniW, miniH = int32(300), int32(100) - func (a *App) SetMiniMode() { if a.isMini { return } - - // Salva posição/tamanho via Win32 (pixels físicos — mesma base do MoveWindow) - wx, wy := a.engine.GetWindowPos() - ww, wh := a.engine.GetWindowSize() - if ww > 200 { - a.origX, a.origY = wx, wy - a.origW, a.origH = ww, wh - } - a.isMini = true runtime.EventsEmit(a.ctx, "window-mode", "mini") - // Posiciona usando Win32 SetWindowPos (evita mistura de coordenadas lógicas/físicas) - // MonitorFromWindow garante monitor correto em setups multi-monitor + // Overlay nativo Win32 no canto — não toca na janela principal mx, my, mw, mh := a.engine.GetMonitorWorkArea() - tx := mx + mw - miniW - 20 - ty := my + mh - miniH - 50 - a.engine.MoveWindow(tx, ty, miniW, miniH, true) // topmost=true + a.overlay.Show(mx, my, mw, mh) } func (a *App) RestoreNormalMode() { @@ -67,9 +51,7 @@ func (a *App) RestoreNormalMode() { } a.isMini = false runtime.EventsEmit(a.ctx, "window-mode", "normal") - - // Restaura via Win32 (mesma base de coordenadas do SetMiniMode) - a.engine.MoveWindow(a.origX, a.origY, a.origW, a.origH, false) + a.overlay.Hide() } // startup is called when the app starts. The context is saved diff --git a/internal/input/input.go b/internal/input/input.go index 14e20f6..3e70f73 100644 --- a/internal/input/input.go +++ b/internal/input/input.go @@ -31,4 +31,12 @@ type InputHandler interface { GetWindowSize() (int32, int32) GetMonitorWorkArea() (x, y, w, h int32) MoveWindow(x, y, w, h int32, topmost bool) + GetDpiScale() float64 + GetForegroundWindow() uintptr + SetForegroundTo(hwnd uintptr) + GetAppHwnd() uintptr + IsMinimized(hwnd uintptr) bool + RestoreWindow(hwnd uintptr) + MinimizeWindow(hwnd uintptr) + RegisterRawScrollSink(callback func(delta int16)) error } diff --git a/internal/input/input_windows.go b/internal/input/input_windows.go index b022f01..6346426 100644 --- a/internal/input/input_windows.go +++ b/internal/input/input_windows.go @@ -29,9 +29,19 @@ var ( procSetForegroundWindow = user32.NewProc("SetForegroundWindow") procGetWindowRect = user32.NewProc("GetWindowRect") procClipCursor = user32.NewProc("ClipCursor") - procSetWindowPos = user32.NewProc("SetWindowPos") - procMonitorFromWindow = user32.NewProc("MonitorFromWindow") - procGetMonitorInfoW = user32.NewProc("GetMonitorInfoW") + procSetWindowPos = user32.NewProc("SetWindowPos") + procMonitorFromWindow = user32.NewProc("MonitorFromWindow") + procGetMonitorInfoW = user32.NewProc("GetMonitorInfoW") + procCreateWindowExW = user32.NewProc("CreateWindowExW") + procRegisterRawInputDevices = user32.NewProc("RegisterRawInputDevices") + procGetRawInputData = user32.NewProc("GetRawInputData") + procGetDpiForWindow = user32.NewProc("GetDpiForWindow") + procGetForegroundWindow = user32.NewProc("GetForegroundWindow") + procIsIconic = user32.NewProc("IsIconic") + procShowWindowAsync = user32.NewProc("ShowWindowAsync") + procGetWindowThreadProcessId = user32.NewProc("GetWindowThreadProcessId") + procAttachThreadInput = user32.NewProc("AttachThreadInput") + procBringWindowToTop = user32.NewProc("BringWindowToTop") ) type RECT struct { @@ -45,6 +55,46 @@ type MONITORINFO struct { DwFlags uint32 } +// Raw Input structs (layout deve bater exatamente com winuser.h) +type RAWINPUTHEADER struct { + DwType uint32 + DwSize uint32 + HDevice uintptr + WParam uintptr +} + +type RAWMOUSE struct { + UsFlags uint16 + _ [2]byte // padding para alinhar union de 4 bytes + UsButtonFlags uint16 + UsButtonData uint16 + UlRawButtons uint32 + LLastX int32 + LLastY int32 + UlExtraInfo uint32 +} + +type RAWINPUT struct { + Header RAWINPUTHEADER + Mouse RAWMOUSE +} + +type RAWINPUTDEVICE struct { + UsUsagePage uint16 + UsUsage uint16 + DwFlags uint32 + HwndTarget uintptr +} + +const ( + WM_INPUT = 0x00FF + RID_INPUT = 0x10000003 + RIM_TYPEMOUSE = 0 + RI_MOUSE_WHEEL = 0x0400 + RIDEV_INPUTSINK = 0x00000100 + HWND_MESSAGE = ^uintptr(2) // -3 +) + const ( WH_KEYBOARD_LL = 13 WH_MOUSE_LL = 14 @@ -85,8 +135,8 @@ func NewInputHandler() InputHandler { } func (h *windowsInputHandler) Install(ctx context.Context, onMouse func(MouseEvent) bool, onKey func(KeyboardEvent) bool) error { - // Captura o HWND da janela ativa (que deve ser o nosso App Wails chamando Install) - hwnd, _, _ := procGetActiveWindow.Call() + // GetForegroundWindow é system-wide (GetActiveWindow é thread-local e pode retornar 0) + hwnd, _, _ := procGetForegroundWindow.Call() h.hwnd = hwnd ready := make(chan error, 1) @@ -194,8 +244,21 @@ func (h *windowsInputHandler) GetScreenResolution() (int32, int32) { } func (h *windowsInputHandler) RequestFocus() { - if h.hwnd != 0 { - procSetForegroundWindow.Call(h.hwnd) + if h.hwnd == 0 { + return + } + // AttachThreadInput trick: único jeito confiável de roubar foco de outra janela no Windows. + // SetForegroundWindow sozinho falha se o processo chamante não é o foreground. + fgHwnd, _, _ := procGetForegroundWindow.Call() + fgTid, _, _ := procGetWindowThreadProcessId.Call(fgHwnd, 0) + myTid := uintptr(windows.GetCurrentThreadId()) + if fgTid != 0 && fgTid != myTid { + procAttachThreadInput.Call(myTid, fgTid, 1) // attach + } + procBringWindowToTop.Call(h.hwnd) + procSetForegroundWindow.Call(h.hwnd) + if fgTid != 0 && fgTid != myTid { + procAttachThreadInput.Call(myTid, fgTid, 0) // detach } } @@ -256,3 +319,128 @@ func (h *windowsInputHandler) MoveWindow(x, y, w, ht int32, topmost bool) { SWP_NOACTIVATE, ) } + +func (h *windowsInputHandler) GetDpiScale() float64 { + if h.hwnd == 0 { + return 1.0 + } + dpi, _, _ := procGetDpiForWindow.Call(h.hwnd) + if dpi == 0 { + return 1.0 + } + return float64(dpi) / 96.0 +} + +func (h *windowsInputHandler) GetForegroundWindow() uintptr { + hwnd, _, _ := procGetForegroundWindow.Call() + return hwnd +} + +func (h *windowsInputHandler) SetForegroundTo(hwnd uintptr) { + if hwnd == 0 { + return + } + fgHwnd, _, _ := procGetForegroundWindow.Call() + fgTid, _, _ := procGetWindowThreadProcessId.Call(fgHwnd, 0) + myTid := uintptr(windows.GetCurrentThreadId()) + if fgTid != 0 && fgTid != myTid { + procAttachThreadInput.Call(myTid, fgTid, 1) + } + procBringWindowToTop.Call(hwnd) + procSetForegroundWindow.Call(hwnd) + if fgTid != 0 && fgTid != myTid { + procAttachThreadInput.Call(myTid, fgTid, 0) + } +} + +func (h *windowsInputHandler) GetAppHwnd() uintptr { + return h.hwnd +} + +func (h *windowsInputHandler) IsMinimized(hwnd uintptr) bool { + ret, _, _ := procIsIconic.Call(hwnd) + return ret != 0 +} + +func (h *windowsInputHandler) RestoreWindow(hwnd uintptr) { + procShowWindowAsync.Call(hwnd, 9) // SW_RESTORE +} + +func (h *windowsInputHandler) MinimizeWindow(hwnd uintptr) { + procShowWindowAsync.Call(hwnd, 6) // SW_MINIMIZE +} + +func (h *windowsInputHandler) RegisterRawScrollSink(callback func(delta int16)) error { + ready := make(chan error, 1) + + go func() { + runtime.LockOSThread() + + // Janela message-only como alvo do Raw Input (sem UI, sem z-order) + className, _ := windows.UTF16PtrFromString("STATIC") + hwndMsg, _, _ := procCreateWindowExW.Call( + 0, + uintptr(unsafe.Pointer(className)), + 0, 0, + 0, 0, 0, 0, + HWND_MESSAGE, // janela oculta sem pai na tela + 0, 0, 0, + ) + if hwndMsg == 0 { + ready <- fmt.Errorf("CreateWindowExW failed") + return + } + + // Registra mouse raw com RIDEV_INPUTSINK: recebe eventos mesmo sem foco/cursor + rid := RAWINPUTDEVICE{ + UsUsagePage: 0x01, // HID_USAGE_PAGE_GENERIC + UsUsage: 0x02, // HID_USAGE_GENERIC_MOUSE + DwFlags: RIDEV_INPUTSINK, + HwndTarget: hwndMsg, + } + ret, _, _ := procRegisterRawInputDevices.Call( + uintptr(unsafe.Pointer(&rid)), + 1, + uintptr(unsafe.Sizeof(rid)), + ) + if ret == 0 { + ready <- fmt.Errorf("RegisterRawInputDevices failed") + return + } + + ready <- nil + + // Loop de mensagens dedicado para WM_INPUT + var msg struct { + Hwnd uintptr + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Pt Point + } + for { + r, _, _ := procGetMessageW.Call(uintptr(unsafe.Pointer(&msg)), hwndMsg, 0, 0) + if r == 0 || msg.Message == WM_QUIT { + break + } + if msg.Message == WM_INPUT { + var raw RAWINPUT + size := uint32(unsafe.Sizeof(raw)) + procGetRawInputData.Call( + msg.LParam, // HRAWINPUT + RID_INPUT, + uintptr(unsafe.Pointer(&raw)), + uintptr(unsafe.Pointer(&size)), + uintptr(unsafe.Sizeof(RAWINPUTHEADER{})), + ) + if raw.Header.DwType == RIM_TYPEMOUSE && + raw.Mouse.UsButtonFlags&RI_MOUSE_WHEEL != 0 { + callback(int16(raw.Mouse.UsButtonData)) + } + } + } + }() + + return <-ready +} diff --git a/internal/input/overlay_windows.go b/internal/input/overlay_windows.go new file mode 100644 index 0000000..655ebea --- /dev/null +++ b/internal/input/overlay_windows.go @@ -0,0 +1,203 @@ +//go:build windows + +package input + +import ( + "runtime" + "sync" + "unsafe" + + "golang.org/x/sys/windows" +) + +var ( + gdi32 = windows.NewLazySystemDLL("gdi32.dll") + + procCreateSolidBrush = gdi32.NewProc("CreateSolidBrush") + procGdiDeleteObject = gdi32.NewProc("DeleteObject") + procGetStockObject = gdi32.NewProc("GetStockObject") + procGdiSelectObject = gdi32.NewProc("SelectObject") + procSetBkMode = gdi32.NewProc("SetBkMode") + procSetTextColor = gdi32.NewProc("SetTextColor") + + procBeginPaint = user32.NewProc("BeginPaint") + procEndPaint = user32.NewProc("EndPaint") + procGetClientRect = user32.NewProc("GetClientRect") + procFillRect = user32.NewProc("FillRect") + procDrawTextW = user32.NewProc("DrawTextW") + procRegisterClassExW = user32.NewProc("RegisterClassExW") + procDefWindowProcW = user32.NewProc("DefWindowProcW") + procUpdateWindow = user32.NewProc("UpdateWindow") + procShowWindowFn = user32.NewProc("ShowWindow") + procTranslateMessage = user32.NewProc("TranslateMessage") + procDispatchMessageW = user32.NewProc("DispatchMessageW") +) + +// WNDCLASSEXW layout: 80 bytes on 64-bit +type WNDCLASSEXW struct { + CbSize uint32 + Style uint32 + LpfnWndProc uintptr + CbClsExtra int32 + CbWndExtra int32 + HInstance uintptr + HIcon uintptr + HCursor uintptr + HbrBackground uintptr + LpszMenuName *uint16 + LpszClassName *uint16 + HIconSm uintptr +} + +// PAINTSTRUCT layout: 72 bytes on 64-bit (includes trailing padding) +type PAINTSTRUCT struct { + Hdc uintptr + FErase int32 + RcPaint RECT + FRestore int32 + FIncUpdate int32 + RgbReserved [32]byte +} + +var ( + overlayOnce sync.Once + overlayCallback uintptr // prevent GC of callback +) + +// OverlayWindow é uma janela Win32 nativa independente do Wails. +// Exibe indicador neon "KVMote" no canto da tela durante modo cliente. +// Não interfere com WebView2, Raw Input, nem coordenadas do Wails. +type OverlayWindow struct { + hwnd uintptr + ready chan struct{} +} + +func NewOverlayWindow() *OverlayWindow { + ov := &OverlayWindow{ready: make(chan struct{})} + go ov.run() + <-ov.ready + return ov +} + +func (ov *OverlayWindow) run() { + runtime.LockOSThread() + + className, _ := windows.UTF16PtrFromString("KVMoteOverlay") + + overlayOnce.Do(func() { + cb := windows.NewCallback(func(hwnd, msg, wParam, lParam uintptr) uintptr { + const ( + wmPaint = 0x000F + wmEraseBkgnd = 0x0014 + dtCenter = 0x00000001 + dtVCenter = 0x00000004 + dtSingleLine = 0x00000020 + transparent = 1 + neonGreen = 0x0014FF39 // #39FF14 em COLORREF (0x00BBGGRR) + defaultFont = 17 // DEFAULT_GUI_FONT + ) + switch msg { + case wmEraseBkgnd: + return 1 + case wmPaint: + var ps PAINTSTRUCT + hdc, _, _ := procBeginPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps))) + var rc RECT + procGetClientRect.Call(hwnd, uintptr(unsafe.Pointer(&rc))) + + brush, _, _ := procCreateSolidBrush.Call(0) // preto + procFillRect.Call(hdc, uintptr(unsafe.Pointer(&rc)), brush) + procGdiDeleteObject.Call(brush) + + font, _, _ := procGetStockObject.Call(defaultFont) + old, _, _ := procGdiSelectObject.Call(hdc, font) + procSetBkMode.Call(hdc, transparent) + procSetTextColor.Call(hdc, neonGreen) + text, _ := windows.UTF16PtrFromString("KVMote") + procDrawTextW.Call(hdc, uintptr(unsafe.Pointer(text)), ^uintptr(0), + uintptr(unsafe.Pointer(&rc)), dtCenter|dtVCenter|dtSingleLine) + procGdiSelectObject.Call(hdc, old) + + procEndPaint.Call(hwnd, uintptr(unsafe.Pointer(&ps))) + return 0 + } + r, _, _ := procDefWindowProcW.Call(hwnd, msg, wParam, lParam) + return r + }) + overlayCallback = cb + + hMod, _, _ := procGetModuleHandleW.Call(0) + wc := WNDCLASSEXW{ + LpfnWndProc: cb, + HInstance: hMod, + LpszClassName: className, + } + wc.CbSize = uint32(unsafe.Sizeof(wc)) + procRegisterClassExW.Call(uintptr(unsafe.Pointer(&wc))) + }) + + const ( + wsPopup = 0x80000000 + wsExTopmost = 0x00000008 + wsExNoActivate = 0x08000000 + wsExToolWindow = 0x00000080 + ) + hMod, _, _ := procGetModuleHandleW.Call(0) + hwnd, _, _ := procCreateWindowExW.Call( + wsExTopmost|wsExNoActivate|wsExToolWindow, + uintptr(unsafe.Pointer(className)), + 0, wsPopup, + 0, 0, 200, 60, + 0, 0, hMod, 0, + ) + ov.hwnd = hwnd + close(ov.ready) + + if hwnd == 0 { + return + } + + var msg struct { + Hwnd uintptr + Message uint32 + WParam uintptr + LParam uintptr + Time uint32 + Pt Point + } + for { + ret, _, _ := procGetMessageW.Call(uintptr(unsafe.Pointer(&msg)), hwnd, 0, 0) + if ret == 0 { + break + } + procTranslateMessage.Call(uintptr(unsafe.Pointer(&msg))) + procDispatchMessageW.Call(uintptr(unsafe.Pointer(&msg))) + } +} + +// Show exibe o overlay no canto superior esquerdo do monitor especificado. +// mx, my, mw, mh: work area do monitor em pixels físicos. +func (ov *OverlayWindow) Show(mx, my, mw, mh int32) { + if ov.hwnd == 0 { + return + } + _ = mw + _ = mh + const overlayW, overlayH = int32(150), int32(40) + const swpNoActivate = 0x0010 + // Canto superior esquerdo do monitor — sem depender de taskbar + x := mx + 10 + y := my + 10 + procSetWindowPos.Call(ov.hwnd, ^uintptr(0), // HWND_TOPMOST + uintptr(x), uintptr(y), uintptr(overlayW), uintptr(overlayH), swpNoActivate) + procShowWindowFn.Call(ov.hwnd, 5) // SW_SHOW + procUpdateWindow.Call(ov.hwnd) +} + +// Hide esconde o overlay. +func (ov *OverlayWindow) Hide() { + if ov.hwnd == 0 { + return + } + procShowWindowFn.Call(ov.hwnd, 0) // SW_HIDE +} diff --git a/internal/kvm/engine.go b/internal/kvm/engine.go index abbdd91..85c04e9 100644 --- a/internal/kvm/engine.go +++ b/internal/kvm/engine.go @@ -46,11 +46,12 @@ type WindowManager interface { } type Engine struct { - mu sync.Mutex - transport transport.Transport - inputHandler input.InputHandler - winManager WindowManager - + mu sync.Mutex + rawScrollOnce sync.Once + transport transport.Transport + inputHandler input.InputHandler + winManager WindowManager + clientMode bool clientPos ClientPos clientLayout ClientLayout @@ -72,6 +73,9 @@ type Engine struct { wheelAccum int32 mouseThrottle time.Time + + prevForegroundHwnd uintptr // janela que estava em foco antes de entrar em client mode + prevWasActive bool // true se salvamos uma janela diferente do KVMote } func NewEngine(t transport.Transport, h input.InputHandler) *Engine { @@ -110,12 +114,52 @@ func (e *Engine) MoveWindow(x, y, w, h int32, topmost bool) { e.inputHandler.MoveWindow(x, y, w, h, topmost) } +func (e *Engine) GetDpiScale() float64 { + return e.inputHandler.GetDpiScale() +} + func (e *Engine) Start(ctx context.Context) error { w, h := e.inputHandler.GetScreenResolution() LogDebug(fmt.Sprintf("Engine Iniciada. Tela: %dx%d. Pos: %v", w, h, e.clientPos)) + + // Registra apenas uma vez — Connect() pode chamar Start() múltiplas vezes + e.rawScrollOnce.Do(func() { + if err := e.inputHandler.RegisterRawScrollSink(e.onRawScroll); err != nil { + LogDebug(fmt.Sprintf("Raw scroll sink falhou: %v", err)) + } else { + LogDebug("Raw scroll sink registrado OK") + } + }) + return e.inputHandler.Install(ctx, e.onMouse, e.onKey) } +func (e *Engine) onRawScroll(delta int16) { + e.mu.Lock() + defer e.mu.Unlock() + + if !e.clientMode || !e.transport.IsConnected() { + return + } + + e.scrollActive = true + e.scrollTimer = time.Now() + + // Mesmo acumulador e divisor do HandleManualScroll + // delta vem em unidades WHEEL_DELTA (±120 por notch) + e.wheelAccum += int32(delta) + const Divisor = 30 + toSend := e.wheelAccum / Divisor + if toSend != 0 { + e.wheelAccum -= toSend * Divisor + val := int8(clamp(int(toSend), -127, 127)) + go func(v int8) { + e.transport.Send([]byte{'W', byte(v)}) + LogDebug(fmt.Sprintf("RAW SCROLL: delta=%d → W=%d", delta, v)) + }(val) + } +} + func (e *Engine) onMouse(ev input.MouseEvent) bool { e.mu.Lock() @@ -151,10 +195,7 @@ func (e *Engine) onMouse(ev input.MouseEvent) bool { case 0x0200: // Move if e.isWarping { e.isWarping = false; return true } - wx, wy := e.inputHandler.GetWindowPos() - if e.scrollActive { - // Durante scroll: decai após 250ms, mas sempre parka cursor na janela if time.Since(e.scrollTimer) > 250*time.Millisecond { e.scrollActive = false e.virtualX, e.virtualY = 0, 0 @@ -180,10 +221,12 @@ func (e *Engine) onMouse(ev input.MouseEvent) bool { } } - // Sempre parka cursor no centro da mini janela (garante alvo para scroll) + // Park no centro da tela (cursor oculto, só para manter delta consistente) + w, h := e.inputHandler.GetScreenResolution() + cx, cy := w/2, h/2 e.isWarping = true - e.inputHandler.SetCursorPos(wx+150, wy+50) - e.lastRawPos = input.Point{X: wx + 150, Y: wy + 50} + e.inputHandler.SetCursorPos(cx, cy) + e.lastRawPos = input.Point{X: cx, Y: cy} return true case 0x0201: e.transport.Send([]byte{'D', 'L'}); return true @@ -226,34 +269,35 @@ func (e *Engine) enterClientMode(p input.Point) { e.pendingDX, e.pendingDY = 0, 0 e.wheelAccum = 0 e.mouseThrottle = time.Now() - - e.inputHandler.RequestFocus() + // Salva janela em foco e traz KVMote pra frente, se necessário + kvHwnd := e.inputHandler.GetAppHwnd() + fgHwnd := e.inputHandler.GetForegroundWindow() + if fgHwnd != kvHwnd { + e.prevForegroundHwnd = fgHwnd + e.prevWasActive = true + if e.inputHandler.IsMinimized(kvHwnd) { + e.inputHandler.RestoreWindow(kvHwnd) + } + e.inputHandler.RequestFocus() + LogDebug(fmt.Sprintf("Janela anterior salva: 0x%X, KVMote trazido ao foco", fgHwnd)) + } else { + e.prevWasActive = false + e.prevForegroundHwnd = 0 + } + + // Mantém processo ativo + mostra overlay if e.winManager != nil { e.winManager.SetMiniMode() } - // Wails move janela de forma assíncrona; aguardar antes de parkear - // ShowCursor(true) DEVE ser após resize/move: Wails/webview pode chamar ShowCursor(false) internamente - go func() { - time.Sleep(250 * time.Millisecond) - e.mu.Lock() - defer e.mu.Unlock() - if !e.clientMode { - return - } - // Força cursor visível (combate ShowCursor(false) interno do Wails/webview) - for i := 0; i < 10; i++ { - e.inputHandler.ShowCursor(true) - } - wx, wy := e.inputHandler.GetWindowPos() - e.isWarping = true - e.inputHandler.SetCursorPos(wx+150, wy+50) - e.lastRawPos = input.Point{X: wx + 150, Y: wy + 50} - LogDebug(fmt.Sprintf("Cursor parkado em mini: (%d,%d)", wx+150, wy+50)) - }() + // Parka cursor no centro — scroll via Raw Input, cursor pode ficar visível + w, h := e.inputHandler.GetScreenResolution() + cx, cy := w/2, h/2 + e.isWarping = true + e.inputHandler.SetCursorPos(cx, cy) + e.lastRawPos = input.Point{X: cx, Y: cy} - // 'A' (ReleaseAll) limpa estados presos no firmware, 'O' sinaliza LED Magenta e.transport.Send([]byte{'A'}) e.transport.Send([]byte{'O'}) } @@ -263,12 +307,19 @@ func (e *Engine) exitClientMode() { e.clientMode = false e.lastModeChange = time.Now() - // Restaura a janela (Normal Mode) if e.winManager != nil { e.winManager.RestoreNormalMode() } e.inputHandler.ShowCursor(true) + + // Restaura janela anterior ao foco + if e.prevWasActive && e.prevForegroundHwnd != 0 { + e.inputHandler.SetForegroundTo(e.prevForegroundHwnd) + LogDebug(fmt.Sprintf("Janela anterior restaurada: 0x%X", e.prevForegroundHwnd)) + e.prevForegroundHwnd = 0 + e.prevWasActive = false + } w, h := e.inputHandler.GetScreenResolution() var ret input.Point const Offset = 120