feat: mudar o foco da janela quando for pro client

This commit is contained in:
Ricardo Carneiro 2026-04-27 09:40:10 -03:00
parent 5d016d7087
commit 1de5a60843
7 changed files with 523 additions and 73 deletions

View File

@ -1,7 +1,8 @@
{ {
"permissions": { "permissions": {
"allow": [ "allow": [
"Bash(go build:*)" "Bash(go build:*)",
"Bash(git:*)"
] ]
} }
} }

View File

@ -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`). 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 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/ internal/
transport/ transport/
transport.go — interface Transport (Detect/Connect/Send/SendLossy...) transport.go — interface Transport (Detect/Connect/Send/SendLossy...)
ble_windows.go — BLE NUS via tinygo/bluetooth ble_windows.go — BLE NUS via tinygo/bluetooth
kvm/ kvm/
engine.go — lógica KVM: mouse, teclado, clipboard, modo cliente engine.go — lógica KVM: mouse, teclado, clipboard, modo cliente
WindowManager interface (SetMiniMode/RestoreNormalMode)
LogDebug → kvmote_debug.log (arquivo na raiz)
input/ input/
input.go — interface InputHandler + tipos (Point, MouseEvent, KeyboardEvent) input.go — interface InputHandler + tipos (Point, MouseEvent, KeyboardEvent)
input_windows.go — hooks Win32, SetCursorPos, ShowCursor, GetSystemMetrics 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/ frontend/
dist/index.html — UI dist/index.html — UI
wails.json — config Wails 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 ## ⚠️ Cuidados
- **Scroll touchpad:** `scrollActive` e `scrollTimer` não devem ser resetados em enter/exitClientMode - **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 - **isWarping flag:** previne loop infinito SetCursorPos → WM_MOUSEMOVE → SetCursorPos
- **Mutex ordering:** engine.mu protege todo estado KVM; transport.mu protege conexão BLE - **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)

38
app.go
View File

@ -14,13 +14,10 @@ import (
// App struct // App struct
type App struct { type App struct {
ctx context.Context ctx context.Context
engine *kvm.Engine engine *kvm.Engine
overlay *input.OverlayWindow
// Estado da janela (pixels físicos Win32) isMini bool
origX, origY int32
origW, origH int32
isMini bool
} }
// NewApp creates a new App application struct // NewApp creates a new App application struct
@ -29,36 +26,23 @@ func NewApp() *App {
h := input.NewInputHandler() h := input.NewInputHandler()
e := kvm.NewEngine(t, h) e := kvm.NewEngine(t, h)
app := &App{ 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 return app
} }
const miniW, miniH = int32(300), int32(100)
func (a *App) SetMiniMode() { func (a *App) SetMiniMode() {
if a.isMini { if a.isMini {
return 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 a.isMini = true
runtime.EventsEmit(a.ctx, "window-mode", "mini") runtime.EventsEmit(a.ctx, "window-mode", "mini")
// Posiciona usando Win32 SetWindowPos (evita mistura de coordenadas lógicas/físicas) // Overlay nativo Win32 no canto — não toca na janela principal
// MonitorFromWindow garante monitor correto em setups multi-monitor
mx, my, mw, mh := a.engine.GetMonitorWorkArea() mx, my, mw, mh := a.engine.GetMonitorWorkArea()
tx := mx + mw - miniW - 20 a.overlay.Show(mx, my, mw, mh)
ty := my + mh - miniH - 50
a.engine.MoveWindow(tx, ty, miniW, miniH, true) // topmost=true
} }
func (a *App) RestoreNormalMode() { func (a *App) RestoreNormalMode() {
@ -67,9 +51,7 @@ func (a *App) RestoreNormalMode() {
} }
a.isMini = false a.isMini = false
runtime.EventsEmit(a.ctx, "window-mode", "normal") runtime.EventsEmit(a.ctx, "window-mode", "normal")
a.overlay.Hide()
// Restaura via Win32 (mesma base de coordenadas do SetMiniMode)
a.engine.MoveWindow(a.origX, a.origY, a.origW, a.origH, false)
} }
// startup is called when the app starts. The context is saved // startup is called when the app starts. The context is saved

View File

@ -31,4 +31,12 @@ type InputHandler interface {
GetWindowSize() (int32, int32) GetWindowSize() (int32, int32)
GetMonitorWorkArea() (x, y, w, h int32) GetMonitorWorkArea() (x, y, w, h int32)
MoveWindow(x, y, w, h int32, topmost bool) 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
} }

View File

@ -29,9 +29,19 @@ var (
procSetForegroundWindow = user32.NewProc("SetForegroundWindow") procSetForegroundWindow = user32.NewProc("SetForegroundWindow")
procGetWindowRect = user32.NewProc("GetWindowRect") procGetWindowRect = user32.NewProc("GetWindowRect")
procClipCursor = user32.NewProc("ClipCursor") procClipCursor = user32.NewProc("ClipCursor")
procSetWindowPos = user32.NewProc("SetWindowPos") procSetWindowPos = user32.NewProc("SetWindowPos")
procMonitorFromWindow = user32.NewProc("MonitorFromWindow") procMonitorFromWindow = user32.NewProc("MonitorFromWindow")
procGetMonitorInfoW = user32.NewProc("GetMonitorInfoW") 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 { type RECT struct {
@ -45,6 +55,46 @@ type MONITORINFO struct {
DwFlags uint32 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 ( const (
WH_KEYBOARD_LL = 13 WH_KEYBOARD_LL = 13
WH_MOUSE_LL = 14 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 { 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) // GetForegroundWindow é system-wide (GetActiveWindow é thread-local e pode retornar 0)
hwnd, _, _ := procGetActiveWindow.Call() hwnd, _, _ := procGetForegroundWindow.Call()
h.hwnd = hwnd h.hwnd = hwnd
ready := make(chan error, 1) ready := make(chan error, 1)
@ -194,8 +244,21 @@ func (h *windowsInputHandler) GetScreenResolution() (int32, int32) {
} }
func (h *windowsInputHandler) RequestFocus() { func (h *windowsInputHandler) RequestFocus() {
if h.hwnd != 0 { if h.hwnd == 0 {
procSetForegroundWindow.Call(h.hwnd) 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, 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
}

View File

@ -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
}

View File

@ -46,11 +46,12 @@ type WindowManager interface {
} }
type Engine struct { type Engine struct {
mu sync.Mutex mu sync.Mutex
transport transport.Transport rawScrollOnce sync.Once
inputHandler input.InputHandler transport transport.Transport
winManager WindowManager inputHandler input.InputHandler
winManager WindowManager
clientMode bool clientMode bool
clientPos ClientPos clientPos ClientPos
clientLayout ClientLayout clientLayout ClientLayout
@ -72,6 +73,9 @@ type Engine struct {
wheelAccum int32 wheelAccum int32
mouseThrottle time.Time 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 { 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) 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 { func (e *Engine) Start(ctx context.Context) error {
w, h := e.inputHandler.GetScreenResolution() w, h := e.inputHandler.GetScreenResolution()
LogDebug(fmt.Sprintf("Engine Iniciada. Tela: %dx%d. Pos: %v", w, h, e.clientPos)) 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) 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 { func (e *Engine) onMouse(ev input.MouseEvent) bool {
e.mu.Lock() e.mu.Lock()
@ -151,10 +195,7 @@ func (e *Engine) onMouse(ev input.MouseEvent) bool {
case 0x0200: // Move case 0x0200: // Move
if e.isWarping { e.isWarping = false; return true } if e.isWarping { e.isWarping = false; return true }
wx, wy := e.inputHandler.GetWindowPos()
if e.scrollActive { if e.scrollActive {
// Durante scroll: decai após 250ms, mas sempre parka cursor na janela
if time.Since(e.scrollTimer) > 250*time.Millisecond { if time.Since(e.scrollTimer) > 250*time.Millisecond {
e.scrollActive = false e.scrollActive = false
e.virtualX, e.virtualY = 0, 0 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.isWarping = true
e.inputHandler.SetCursorPos(wx+150, wy+50) e.inputHandler.SetCursorPos(cx, cy)
e.lastRawPos = input.Point{X: wx + 150, Y: wy + 50} e.lastRawPos = input.Point{X: cx, Y: cy}
return true return true
case 0x0201: e.transport.Send([]byte{'D', 'L'}); 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.pendingDX, e.pendingDY = 0, 0
e.wheelAccum = 0 e.wheelAccum = 0
e.mouseThrottle = time.Now() 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 { if e.winManager != nil {
e.winManager.SetMiniMode() e.winManager.SetMiniMode()
} }
// Wails move janela de forma assíncrona; aguardar antes de parkear // Parka cursor no centro — scroll via Raw Input, cursor pode ficar visível
// ShowCursor(true) DEVE ser após resize/move: Wails/webview pode chamar ShowCursor(false) internamente w, h := e.inputHandler.GetScreenResolution()
go func() { cx, cy := w/2, h/2
time.Sleep(250 * time.Millisecond) e.isWarping = true
e.mu.Lock() e.inputHandler.SetCursorPos(cx, cy)
defer e.mu.Unlock() e.lastRawPos = input.Point{X: cx, Y: cy}
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))
}()
// 'A' (ReleaseAll) limpa estados presos no firmware, 'O' sinaliza LED Magenta
e.transport.Send([]byte{'A'}) e.transport.Send([]byte{'A'})
e.transport.Send([]byte{'O'}) e.transport.Send([]byte{'O'})
} }
@ -263,12 +307,19 @@ func (e *Engine) exitClientMode() {
e.clientMode = false e.clientMode = false
e.lastModeChange = time.Now() e.lastModeChange = time.Now()
// Restaura a janela (Normal Mode)
if e.winManager != nil { if e.winManager != nil {
e.winManager.RestoreNormalMode() e.winManager.RestoreNormalMode()
} }
e.inputHandler.ShowCursor(true) 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() w, h := e.inputHandler.GetScreenResolution()
var ret input.Point var ret input.Point
const Offset = 120 const Offset = 120