From 5d016d7087d0aaf4805fb2e083d68ef8bdd3b26b Mon Sep 17 00:00:00 2001 From: Ricardo Carneiro Date: Fri, 24 Apr 2026 16:51:00 -0300 Subject: [PATCH] WIP: mini-window approach for scroll capture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Tentativa de capturar scroll via mini janela sempre-no-topo. Inclui: MoveWindow/GetMonitorWorkArea via Win32, park consistente em wx+150/wy+50, ShowCursor loop no goroutine pós-resize. Revertível: git checkout HEAD~1 Co-Authored-By: Claude Sonnet 4.6 --- .claude/settings.local.json | 7 + app.go | 48 ++++- frontend/dist/index.html | 166 +++++++++++------- frontend/src/wailsjs/wailsjs/go/main/App.d.ts | 4 + frontend/src/wailsjs/wailsjs/go/main/App.js | 8 + internal/input/input.go | 4 + internal/input/input_windows.go | 61 +++++++ internal/kvm/engine.go | 145 +++++++++------ main.go | 11 ++ 9 files changed, 336 insertions(+), 118 deletions(-) create mode 100644 .claude/settings.local.json diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..b55e75b --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "Bash(go build:*)" + ] + } +} diff --git a/app.go b/app.go index 351d762..37599f0 100644 --- a/app.go +++ b/app.go @@ -8,12 +8,19 @@ import ( "kvmote/internal/input" "kvmote/internal/kvm" "kvmote/internal/transport" + + "github.com/wailsapp/wails/v2/pkg/runtime" ) // 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 } // NewApp creates a new App application struct @@ -21,9 +28,48 @@ func NewApp() *App { t := transport.NewBleTransport() h := input.NewInputHandler() e := kvm.NewEngine(t, h) - return &App{ + app := &App{ engine: e, } + e.SetWindowManager(app) // Vincula o app como gerenciador de janela da engine + 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 + 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 +} + +func (a *App) RestoreNormalMode() { + if !a.isMini { + return + } + 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) } // startup is called when the app starts. The context is saved diff --git a/frontend/dist/index.html b/frontend/dist/index.html index 2a75965..2333456 100644 --- a/frontend/dist/index.html +++ b/frontend/dist/index.html @@ -115,72 +115,97 @@ font-weight: bold; } .btn-cad { margin-top: 10px; background: #2a2a2a; color: #ccc; } + + /* Estilo Neon para Modo Mini */ + .neon-container { + display: flex; + align-items: center; + justify-content: center; + height: 100vh; + background-color: black; + cursor: default !important; /* Força cursor visível */ + } + .neon-text { + font-size: 1.6rem; + font-weight: bold; + color: #39FF14; + text-shadow: 0 0 5px #39FF14, 0 0 10px #39FF14; + letter-spacing: 1px; + } - -
-
Posição do PC Cliente:
- -
- - + + -
-
- - -
+ - - -
-
- - -
-
-
- -
+
@@ -197,6 +222,31 @@ statusText: 'Desconectado', pos: 'right', layout: 1, + mode: 'normal', + scrollCount: 0, + init() { + if (window.runtime) { + window.runtime.EventsOn("window-mode", (m) => { + this.mode = m; + }); + } + + // Listener de Scroll interno ao kvmApp para acessar scrollCount + window.addEventListener('wheel', (event) => { + if (this.mode === 'mini') { + this.scrollCount++; + // Feedback visual rápido + const bg = document.getElementById('mini-bg'); + if (bg) { + bg.style.backgroundColor = '#1a3300'; + setTimeout(() => bg.style.backgroundColor = 'black', 50); + } + } + if (window.go && window.go.main && window.go.main.App) { + window.go.main.App.HandleScroll(Math.round(event.deltaY)); + } + }, { passive: false }); + }, toggleConnect() { if (this.connected) { window.go.main.App.Disconnect().then(res => { @@ -229,9 +279,7 @@ // Captura o scroll globalmente na janela do KVMote window.addEventListener('wheel', (event) => { - // Log no console do navegador para você ver se disparar (inspecionar elemento) console.log("Wheel event:", event.deltaY); - if (window.go && window.go.main && window.go.main.App) { window.go.main.App.HandleScroll(Math.round(event.deltaY)); } diff --git a/frontend/src/wailsjs/wailsjs/go/main/App.d.ts b/frontend/src/wailsjs/wailsjs/go/main/App.d.ts index d5b214f..b836a35 100644 --- a/frontend/src/wailsjs/wailsjs/go/main/App.d.ts +++ b/frontend/src/wailsjs/wailsjs/go/main/App.d.ts @@ -9,6 +9,10 @@ export function Disconnect():Promise; export function HandleScroll(arg1:number):Promise; +export function RestoreNormalMode():Promise; + export function SendCtrlAltDel():Promise; +export function SetMiniMode():Promise; + export function SetPosition(arg1:number):Promise; diff --git a/frontend/src/wailsjs/wailsjs/go/main/App.js b/frontend/src/wailsjs/wailsjs/go/main/App.js index 31c1e36..3dc1f11 100644 --- a/frontend/src/wailsjs/wailsjs/go/main/App.js +++ b/frontend/src/wailsjs/wailsjs/go/main/App.js @@ -18,10 +18,18 @@ export function HandleScroll(arg1) { return window['go']['main']['App']['HandleScroll'](arg1); } +export function RestoreNormalMode() { + return window['go']['main']['App']['RestoreNormalMode'](); +} + export function SendCtrlAltDel() { return window['go']['main']['App']['SendCtrlAltDel'](); } +export function SetMiniMode() { + return window['go']['main']['App']['SetMiniMode'](); +} + export function SetPosition(arg1) { return window['go']['main']['App']['SetPosition'](arg1); } diff --git a/internal/input/input.go b/internal/input/input.go index b0713d7..14e20f6 100644 --- a/internal/input/input.go +++ b/internal/input/input.go @@ -27,4 +27,8 @@ type InputHandler interface { GetScreenResolution() (int32, int32) RequestFocus() SetCursorClip(clip bool) + GetWindowPos() (int32, int32) + GetWindowSize() (int32, int32) + GetMonitorWorkArea() (x, y, w, h int32) + MoveWindow(x, y, w, h int32, topmost bool) } diff --git a/internal/input/input_windows.go b/internal/input/input_windows.go index 6b026bb..b022f01 100644 --- a/internal/input/input_windows.go +++ b/internal/input/input_windows.go @@ -29,12 +29,22 @@ 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") ) type RECT struct { Left, Top, Right, Bottom int32 } +type MONITORINFO struct { + CbSize uint32 + RcMonitor RECT + RcWork RECT + DwFlags uint32 +} + const ( WH_KEYBOARD_LL = 13 WH_MOUSE_LL = 14 @@ -195,3 +205,54 @@ func (h *windowsInputHandler) SetCursorClip(clip bool) { procClipCursor.Call(0) } } + +func (h *windowsInputHandler) GetWindowPos() (int32, int32) { + if h.hwnd != 0 { + var rect RECT + procGetWindowRect.Call(h.hwnd, uintptr(unsafe.Pointer(&rect))) + return rect.Left, rect.Top + } + return 0, 0 +} + +func (h *windowsInputHandler) GetWindowSize() (int32, int32) { + if h.hwnd != 0 { + var rect RECT + procGetWindowRect.Call(h.hwnd, uintptr(unsafe.Pointer(&rect))) + return rect.Right - rect.Left, rect.Bottom - rect.Top + } + return 0, 0 +} + +func (h *windowsInputHandler) GetMonitorWorkArea() (x, y, w, ht int32) { + if h.hwnd == 0 { + return 0, 0, 1920, 1080 + } + monitor, _, _ := procMonitorFromWindow.Call(h.hwnd, 2) // MONITOR_DEFAULTTONEAREST + if monitor == 0 { + return 0, 0, 1920, 1080 + } + var mi MONITORINFO + mi.CbSize = uint32(unsafe.Sizeof(mi)) + procGetMonitorInfoW.Call(monitor, uintptr(unsafe.Pointer(&mi))) + return mi.RcWork.Left, mi.RcWork.Top, + mi.RcWork.Right - mi.RcWork.Left, + mi.RcWork.Bottom - mi.RcWork.Top +} + +func (h *windowsInputHandler) MoveWindow(x, y, w, ht int32, topmost bool) { + if h.hwnd == 0 { + return + } + // HWND_TOPMOST = -1, HWND_NOTOPMOST = -2 + zorder := ^uintptr(1) // HWND_NOTOPMOST + if topmost { + zorder = ^uintptr(0) // HWND_TOPMOST + } + const SWP_NOACTIVATE = 0x0010 + procSetWindowPos.Call( + h.hwnd, zorder, + uintptr(x), uintptr(y), uintptr(w), uintptr(ht), + SWP_NOACTIVATE, + ) +} diff --git a/internal/kvm/engine.go b/internal/kvm/engine.go index f57e73d..abbdd91 100644 --- a/internal/kvm/engine.go +++ b/internal/kvm/engine.go @@ -40,10 +40,16 @@ const ( LayoutUsIntl ) +type WindowManager interface { + SetMiniMode() + RestoreNormalMode() +} + type Engine struct { mu sync.Mutex transport transport.Transport inputHandler input.InputHandler + winManager WindowManager clientMode bool clientPos ClientPos @@ -76,35 +82,40 @@ func NewEngine(t transport.Transport, h input.InputHandler) *Engine { } } +func (e *Engine) SetWindowManager(wm WindowManager) { + e.winManager = wm +} + func (e *Engine) Transport() transport.Transport { return e.transport } +func (e *Engine) GetScreenResolution() (int32, int32) { + return e.inputHandler.GetScreenResolution() +} + +func (e *Engine) GetWindowSize() (int32, int32) { + return e.inputHandler.GetWindowSize() +} + +func (e *Engine) GetWindowPos() (int32, int32) { + return e.inputHandler.GetWindowPos() +} + +func (e *Engine) GetMonitorWorkArea() (x, y, w, h int32) { + return e.inputHandler.GetMonitorWorkArea() +} + +func (e *Engine) MoveWindow(x, y, w, h int32, topmost bool) { + e.inputHandler.MoveWindow(x, y, w, h, topmost) +} + 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)) return e.inputHandler.Install(ctx, e.onMouse, e.onKey) } -func (e *Engine) processarScroll(msg uint32, data uint32) { - e.scrollActive = true - e.scrollTimer = time.Now() - - deltaRaw := int16(data >> 16) - - // LOG TOTAL PARA DESCOBRIR O QUE O TOUCHPAD MANDA - go LogDebug(fmt.Sprintf("SCROLL RAW: msg=0x%X data=0x%X delta=%d", msg, data, deltaRaw)) - - if deltaRaw > 0 { - e.transport.Send([]byte{'P', 0xDA}) // Up - time.Sleep(5 * time.Millisecond) - e.transport.Send([]byte{'U', 0xDA}) - } else if deltaRaw < 0 { - e.transport.Send([]byte{'P', 0xD9}) // Down - time.Sleep(5 * time.Millisecond) - e.transport.Send([]byte{'U', 0xD9}) - } -} func (e *Engine) onMouse(ev input.MouseEvent) bool { e.mu.Lock() @@ -131,50 +142,48 @@ func (e *Engine) onMouse(ev input.MouseEvent) bool { switch ev.Message { case 0x020A, 0x020E: // Roda Vertical ou Horizontal - e.processarScroll(ev.Message, ev.Data) - return true + // Não bloquear: evento passa para o webview → JS wheel → HandleManualScroll + // (WH_MOUSE_LL não recebe scroll de touchpad precision; mini janela captura via JS) + e.scrollActive = true + e.scrollTimer = time.Now() + return false case 0x0200: // Move if e.isWarping { e.isWarping = false; return true } + wx, wy := e.inputHandler.GetWindowPos() + if e.scrollActive { - // Se estiver scrollando, ignoramos movimentos por um tempo curto (touchpads) + // 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 } - e.lastRawPos = ev.Point - return true - } + } else { + dx, dy := ev.Point.X-e.lastRawPos.X, ev.Point.Y-e.lastRawPos.Y + e.virtualX += dx + e.virtualY += dy + e.pendingDX += dx + e.pendingDY += dy - dx, dy := ev.Point.X - e.lastRawPos.X, ev.Point.Y - e.lastRawPos.Y - e.virtualX += dx - e.virtualY += dy - e.pendingDX += dx - e.pendingDY += dy - - if e.shouldReturnToHost() { - if time.Since(e.lastModeChange) > 800*time.Millisecond { - e.exitClientMode() - return true + if e.shouldReturnToHost() { + if time.Since(e.lastModeChange) > 800*time.Millisecond { + e.exitClientMode() + return true + } + e.virtualX, e.virtualY = 0, 0 + } else if time.Since(e.mouseThrottle) >= 40*time.Millisecond { + e.mouseThrottle = time.Now() + sdx, sdy := int8(clamp(int(e.pendingDX), -127, 127)), int8(clamp(int(e.pendingDY), -127, 127)) + e.pendingDX, e.pendingDY = 0, 0 + e.transport.SendLossy([]byte{'M', byte(sdx), byte(sdy)}) } - e.virtualX, e.virtualY = 0, 0 - return true } - // Park no centro para manter o mouse sobre a janela do App - // permitindo a captura do scroll. - w, h := e.inputHandler.GetScreenResolution() + // Sempre parka cursor no centro da mini janela (garante alvo para scroll) e.isWarping = true - e.inputHandler.SetCursorPos(w/2, h/2) - e.lastRawPos = input.Point{X: w / 2, Y: h / 2} - - if time.Since(e.mouseThrottle) >= 40*time.Millisecond { - e.mouseThrottle = time.Now() - sdx, sdy := int8(clamp(int(e.pendingDX), -127, 127)), int8(clamp(int(e.pendingDY), -127, 127)) - e.pendingDX, e.pendingDY = 0, 0 - e.transport.SendLossy([]byte{'M', byte(sdx), byte(sdy)}) - } + e.inputHandler.SetCursorPos(wx+150, wy+50) + e.lastRawPos = input.Point{X: wx + 150, Y: wy + 50} return true case 0x0201: e.transport.Send([]byte{'D', 'L'}); return true @@ -218,17 +227,31 @@ func (e *Engine) enterClientMode(p input.Point) { e.wheelAccum = 0 e.mouseThrottle = time.Now() - // Foco para receber scroll da interface e.inputHandler.RequestFocus() - w, h := e.inputHandler.GetScreenResolution() - e.isWarping = true - // Park no centro como no início - e.inputHandler.SetCursorPos(w/2, h/2) - e.lastRawPos = input.Point{X: w / 2, Y: h / 2} - - // Sem esconder cursor para teste de scroll puro - e.inputHandler.ShowCursor(true) + 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)) + }() // 'A' (ReleaseAll) limpa estados presos no firmware, 'O' sinaliza LED Magenta e.transport.Send([]byte{'A'}) @@ -239,6 +262,12 @@ func (e *Engine) exitClientMode() { LogDebug("Saindo Modo Cliente.") e.clientMode = false e.lastModeChange = time.Now() + + // Restaura a janela (Normal Mode) + if e.winManager != nil { + e.winManager.RestoreNormalMode() + } + e.inputHandler.ShowCursor(true) w, h := e.inputHandler.GetScreenResolution() var ret input.Point diff --git a/main.go b/main.go index 7bb520a..cc25ef1 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "github.com/wailsapp/wails/v2" "github.com/wailsapp/wails/v2/pkg/options" "github.com/wailsapp/wails/v2/pkg/options/assetserver" + "github.com/wailsapp/wails/v2/pkg/options/windows" ) //go:embed all:frontend/dist @@ -20,6 +21,11 @@ func main() { Title: "KVMote", Width: 400, Height: 550, + DisableResize: false, + Fullscreen: false, + Frameless: false, + MinWidth: 200, + MinHeight: 50, AssetServer: &assetserver.Options{ Assets: assets, }, @@ -28,6 +34,11 @@ func main() { Bind: []interface{}{ app, }, + Windows: &windows.Options{ + DisableWindowIcon: false, + WebviewIsTransparent: false, + WindowIsTranslucent: false, + }, }) if err != nil {