Skip to content

Commit d52e7d0

Browse files
authored
feat: relative mouse (#246)
1 parent e426515 commit d52e7d0

File tree

6 files changed

+132
-60
lines changed

6 files changed

+132
-60
lines changed

jsonrpc.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -799,6 +799,7 @@ var rpcHandlers = map[string]RPCHandler{
799799
"getCloudState": {Func: rpcGetCloudState},
800800
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
801801
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
802+
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
802803
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
803804
"getVideoState": {Func: rpcGetVideoState},
804805
"getUSBState": {Func: rpcGetUSBState},

ui/src/components/InfoBar.tsx

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ export default function InfoBar() {
1414
const activeModifiers = useHidStore(state => state.activeModifiers);
1515
const mouseX = useMouseStore(state => state.mouseX);
1616
const mouseY = useMouseStore(state => state.mouseY);
17+
const mouseMove = useMouseStore(state => state.mouseMove);
1718

1819
const videoClientSize = useVideoStore(
1920
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
@@ -62,7 +63,7 @@ export default function InfoBar() {
6263
</div>
6364
) : null}
6465

65-
{settings.debugMode ? (
66+
{(settings.debugMode && settings.mouseMode == "absolute") ? (
6667
<div className="flex w-[118px] items-center gap-x-1">
6768
<span className="text-xs font-semibold">Pointer:</span>
6869
<span className="text-xs">
@@ -71,6 +72,17 @@ export default function InfoBar() {
7172
</div>
7273
) : null}
7374

75+
{(settings.debugMode && settings.mouseMode == "relative") ? (
76+
<div className="flex w-[118px] items-center gap-x-1">
77+
<span className="text-xs font-semibold">Last Move:</span>
78+
<span className="text-xs">
79+
{mouseMove ?
80+
`${mouseMove.x},${mouseMove.y} ${mouseMove.buttons ? `(${mouseMove.buttons})` : ""}` :
81+
"N/A"}
82+
</span>
83+
</div>
84+
) : null}
85+
7486
{settings.debugMode && (
7587
<div className="flex w-[156px] items-center gap-x-1">
7688
<span className="text-xs font-semibold">USB State:</span>

ui/src/components/WebRTCVideo.tsx

Lines changed: 63 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ export default function WebRTCVideo() {
2929
const settings = useSettingsStore();
3030
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
3131
const setMousePosition = useMouseStore(state => state.setMousePosition);
32+
const setMouseMove = useMouseStore(state => state.setMouseMove);
3233
const {
3334
setClientSize: setVideoClientSize,
3435
setSize: setVideoSize,
@@ -93,19 +94,44 @@ export default function WebRTCVideo() {
9394
);
9495

9596
// Mouse-related
96-
const sendMouseMovement = useCallback(
97+
const calcDelta = (pos: number) => Math.abs(pos) < 10 ? pos * 2 : pos;
98+
const sendRelMouseMovement = useCallback(
9799
(x: number, y: number, buttons: number) => {
98-
send("absMouseReport", { x, y, buttons });
100+
if (settings.mouseMode !== "relative") return;
101+
// if we ignore the event, double-click will not work
102+
// if (x === 0 && y === 0 && buttons === 0) return;
103+
send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons });
104+
setMouseMove({ x, y, buttons });
105+
},
106+
[send, setMouseMove, settings.mouseMode],
107+
);
99108

109+
const relMouseMoveHandler = useCallback(
110+
(e: MouseEvent) => {
111+
if (settings.mouseMode !== "relative") return;
112+
113+
// Send mouse movement
114+
const { buttons } = e;
115+
sendRelMouseMovement(e.movementX, e.movementY, buttons);
116+
},
117+
[sendRelMouseMovement, settings.mouseMode],
118+
);
119+
120+
const sendAbsMouseMovement = useCallback(
121+
(x: number, y: number, buttons: number) => {
122+
if (settings.mouseMode !== "absolute") return;
123+
send("absMouseReport", { x, y, buttons });
100124
// We set that for the debug info bar
101125
setMousePosition(x, y);
102126
},
103-
[send, setMousePosition],
127+
[send, setMousePosition, settings.mouseMode],
104128
);
105129

106-
const mouseMoveHandler = useCallback(
130+
const absMouseMoveHandler = useCallback(
107131
(e: MouseEvent) => {
108132
if (!videoClientWidth || !videoClientHeight) return;
133+
if (settings.mouseMode !== "absolute") return;
134+
109135
// Get the aspect ratios of the video element and the video stream
110136
const videoElementAspectRatio = videoClientWidth / videoClientHeight;
111137
const videoStreamAspectRatio = videoWidth / videoHeight;
@@ -140,9 +166,9 @@ export default function WebRTCVideo() {
140166

141167
// Send mouse movement
142168
const { buttons } = e;
143-
sendMouseMovement(x, y, buttons);
169+
sendAbsMouseMovement(x, y, buttons);
144170
},
145-
[sendMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight],
171+
[sendAbsMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight, settings.mouseMode],
146172
);
147173

148174
const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity);
@@ -193,8 +219,8 @@ export default function WebRTCVideo() {
193219
);
194220

195221
const resetMousePosition = useCallback(() => {
196-
sendMouseMovement(0, 0, 0);
197-
}, [sendMouseMovement]);
222+
sendAbsMouseMovement(0, 0, 0);
223+
}, [sendAbsMouseMovement]);
198224

199225
// Keyboard-related
200226
const handleModifierKeys = useCallback(
@@ -371,9 +397,9 @@ export default function WebRTCVideo() {
371397
const abortController = new AbortController();
372398
const signal = abortController.signal;
373399

374-
videoElmRefValue.addEventListener("mousemove", mouseMoveHandler, { signal });
375-
videoElmRefValue.addEventListener("pointerdown", mouseMoveHandler, { signal });
376-
videoElmRefValue.addEventListener("pointerup", mouseMoveHandler, { signal });
400+
videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal });
401+
videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal });
402+
videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal });
377403
videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal });
378404
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
379405
signal,
@@ -395,14 +421,39 @@ export default function WebRTCVideo() {
395421
};
396422
},
397423
[
398-
mouseMoveHandler,
424+
absMouseMoveHandler,
399425
resetMousePosition,
400426
onVideoPlaying,
401427
mouseWheelHandler,
402428
videoKeyUpHandler,
403429
],
404430
);
405431

432+
useEffect(
433+
function setupRelativeMouseEventListeners() {
434+
if (settings.mouseMode !== "relative") return;
435+
436+
const abortController = new AbortController();
437+
const signal = abortController.signal;
438+
439+
// bind to body to capture all mouse events
440+
const body = document.querySelector("body");
441+
if (!body) return;
442+
443+
body.addEventListener("mousemove", relMouseMoveHandler, { signal });
444+
body.addEventListener("pointerdown", relMouseMoveHandler, { signal });
445+
body.addEventListener("pointerup", relMouseMoveHandler, { signal });
446+
447+
return () => {
448+
abortController.abort();
449+
450+
body.removeEventListener("mousemove", relMouseMoveHandler);
451+
body.removeEventListener("pointerdown", relMouseMoveHandler);
452+
body.removeEventListener("pointerup", relMouseMoveHandler);
453+
};
454+
}, [settings.mouseMode, relMouseMoveHandler],
455+
)
456+
406457
useEffect(
407458
function updateVideoStream() {
408459
if (!mediaStream) return;

ui/src/hooks/stores.ts

Lines changed: 20 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -197,15 +197,23 @@ export const useRTCStore = create<RTCState>(set => ({
197197
setTerminalChannel: channel => set({ terminalChannel: channel }),
198198
}));
199199

200+
interface MouseMove {
201+
x: number;
202+
y: number;
203+
buttons: number;
204+
}
200205
interface MouseState {
201206
mouseX: number;
202207
mouseY: number;
208+
mouseMove?: MouseMove;
209+
setMouseMove: (move?: MouseMove) => void;
203210
setMousePosition: (x: number, y: number) => void;
204211
}
205212

206213
export const useMouseStore = create<MouseState>(set => ({
207214
mouseX: 0,
208215
mouseY: 0,
216+
setMouseMove: (move?: MouseMove) => set({ mouseMove: move }),
209217
setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }),
210218
}));
211219

@@ -543,12 +551,12 @@ export interface UpdateState {
543551
setOtaState: (state: UpdateState["otaState"]) => void;
544552
setUpdateDialogHasBeenMinimized: (hasBeenMinimized: boolean) => void;
545553
modalView:
546-
| "loading"
547-
| "updating"
548-
| "upToDate"
549-
| "updateAvailable"
550-
| "updateCompleted"
551-
| "error";
554+
| "loading"
555+
| "updating"
556+
| "upToDate"
557+
| "updateAvailable"
558+
| "updateCompleted"
559+
| "error";
552560
setModalView: (view: UpdateState["modalView"]) => void;
553561
setUpdateErrorMessage: (errorMessage: string) => void;
554562
updateErrorMessage: string | null;
@@ -612,12 +620,12 @@ export const useUsbConfigModalStore = create<UsbConfigModalState>(set => ({
612620

613621
interface LocalAuthModalState {
614622
modalView:
615-
| "createPassword"
616-
| "deletePassword"
617-
| "updatePassword"
618-
| "creationSuccess"
619-
| "deleteSuccess"
620-
| "updateSuccess";
623+
| "createPassword"
624+
| "deletePassword"
625+
| "updatePassword"
626+
| "creationSuccess"
627+
| "deleteSuccess"
628+
| "updateSuccess";
621629
setModalView: (view: LocalAuthModalState["modalView"]) => void;
622630
}
623631

ui/src/routes/devices.$id.settings.mouse.tsx

Lines changed: 29 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -1,23 +1,27 @@
1-
import { SettingsPageHeader } from "@components/SettingsPageheader";
2-
import { SettingsItem } from "./devices.$id.settings";
3-
import { Checkbox } from "@/components/Checkbox";
4-
import { GridCard } from "@/components/Card";
1+
import MouseIcon from "@/assets/mouse-icon.svg";
52
import PointingFinger from "@/assets/pointing-finger.svg";
6-
import { CheckCircleIcon } from "@heroicons/react/16/solid";
3+
import { GridCard } from "@/components/Card";
4+
import { Checkbox } from "@/components/Checkbox";
75
import { useDeviceSettingsStore, useSettingsStore } from "@/hooks/stores";
6+
import { useJsonRpc } from "@/hooks/useJsonRpc";
87
import notifications from "@/notifications";
8+
import { SettingsPageHeader } from "@components/SettingsPageheader";
9+
import { CheckCircleIcon } from "@heroicons/react/16/solid";
910
import { useCallback, useEffect, useState } from "react";
10-
import { useJsonRpc } from "@/hooks/useJsonRpc";
11-
import { cx } from "../cva.config";
11+
import { FeatureFlag } from "../components/FeatureFlag";
1212
import { SelectMenuBasic } from "../components/SelectMenuBasic";
1313
import { useFeatureFlag } from "../hooks/useFeatureFlag";
14-
import { FeatureFlag } from "../components/FeatureFlag";
14+
import { SettingsItem } from "./devices.$id.settings";
1515

1616
type ScrollSensitivity = "low" | "default" | "high";
1717

1818
export default function SettingsKeyboardMouseRoute() {
1919
const hideCursor = useSettingsStore(state => state.isCursorHidden);
2020
const setHideCursor = useSettingsStore(state => state.setCursorVisibility);
21+
22+
const mouseMode = useSettingsStore(state => state.mouseMode);
23+
const setMouseMode = useSettingsStore(state => state.setMouseMode);
24+
2125
const scrollSensitivity = useDeviceSettingsStore(state => state.scrollSensitivity);
2226
const setScrollSensitivity = useDeviceSettingsStore(
2327
state => state.setScrollSensitivity,
@@ -122,19 +126,19 @@ export default function SettingsKeyboardMouseRoute() {
122126
</SettingsItem>
123127
<div className="space-y-4">
124128
<SettingsItem title="Modes" description="Choose the mouse input mode" />
125-
<div className="flex flex-col items-center gap-4 md:flex-row">
129+
<div className="flex items-center gap-4">
126130
<button
127-
className="group block w-full grow"
128-
onClick={() => console.log("Absolute mouse mode clicked")}
131+
className="block group grow"
132+
onClick={() => { setMouseMode("absolute"); }}
129133
>
130134
<GridCard>
131-
<div className="group flex items-center gap-x-4 px-4 py-3">
135+
<div className="flex items-center px-4 py-3 group gap-x-4">
132136
<img
133137
className="w-6 shrink-0 dark:invert"
134138
src={PointingFinger}
135139
alt="Finger touching a screen"
136140
/>
137-
<div className="flex grow items-center justify-between">
141+
<div className="flex items-center justify-between grow">
138142
<div className="text-left">
139143
<h3 className="text-sm font-semibold text-black dark:text-white">
140144
Absolute
@@ -143,41 +147,32 @@ export default function SettingsKeyboardMouseRoute() {
143147
Most convenient
144148
</p>
145149
</div>
146-
<CheckCircleIcon
147-
className={cx(
148-
"h-4 w-4 text-blue-700 transition-opacity duration-300 dark:text-blue-500",
149-
)}
150-
/>
150+
{mouseMode === "absolute" && (
151+
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
152+
)}
151153
</div>
152154
</div>
153155
</GridCard>
154156
</button>
155157
<button
156-
className="group block w-full grow cursor-not-allowed opacity-50"
157-
disabled
158+
className="block group grow"
159+
onClick={() => { setMouseMode("relative"); }}
158160
>
159161
<GridCard>
160-
<div className="group flex items-center gap-x-4 px-4 py-3">
161-
<img
162-
className="w-6 shrink-0 dark:invert"
163-
src={PointingFinger}
164-
alt="Finger touching a screen"
165-
/>
166-
<div className="flex grow items-center justify-between">
162+
<div className="flex items-center px-4 py-3 gap-x-4">
163+
<img className="w-6 shrink-0 dark:invert" src={MouseIcon} alt="Mouse icon" />
164+
<div className="flex items-center justify-between grow">
167165
<div className="text-left">
168166
<h3 className="text-sm font-semibold text-black dark:text-white">
169167
Relative
170168
</h3>
171169
<p className="text-xs leading-none text-slate-800 dark:text-slate-300">
172-
Most Compatible
170+
Most Compatible (Beta)
173171
</p>
174172
</div>
175-
<CheckCircleIcon
176-
className={cx(
177-
"hidden",
178-
"h-4 w-4 text-blue-700 transition-opacity duration-300 dark:text-blue-500",
179-
)}
180-
/>
173+
{mouseMode === "relative" && (
174+
<CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
175+
)}
181176
</div>
182177
</div>
183178
</GridCard>

usb.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
package kvm
22

33
import (
4-
"github.com/jetkvm/kvm/internal/usbgadget"
54
"time"
5+
6+
"github.com/jetkvm/kvm/internal/usbgadget"
67
)
78

89
var gadget *usbgadget.UsbGadget
@@ -33,6 +34,10 @@ func rpcAbsMouseReport(x, y int, buttons uint8) error {
3334
return gadget.AbsMouseReport(x, y, buttons)
3435
}
3536

37+
func rpcRelMouseReport(dx, dy int8, buttons uint8) error {
38+
return gadget.RelMouseReport(dx, dy, buttons)
39+
}
40+
3641
func rpcWheelReport(wheelY int8) error {
3742
return gadget.AbsMouseWheelReport(wheelY)
3843
}

0 commit comments

Comments
 (0)