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:
-
-
-
-
+
+
+
+
Posição do PC Cliente:
-
-
-
-
-
[HOST PC]
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
+
+
+
+
+
[HOST PC]
+
+
+
+
+
+
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
-
+
+
+
-
-
-
-
-
-
-
-
-
-
-
+
@@ -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 {