feat: relative mouse (#246)

This commit is contained in:
Aveline 2025-03-19 11:47:15 +01:00 committed by GitHub
parent e426515ce9
commit d52e7d04d1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 133 additions and 61 deletions

View File

@ -799,6 +799,7 @@ var rpcHandlers = map[string]RPCHandler{
"getCloudState": {Func: rpcGetCloudState}, "getCloudState": {Func: rpcGetCloudState},
"keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}}, "keyboardReport": {Func: rpcKeyboardReport, Params: []string{"modifier", "keys"}},
"absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}}, "absMouseReport": {Func: rpcAbsMouseReport, Params: []string{"x", "y", "buttons"}},
"relMouseReport": {Func: rpcRelMouseReport, Params: []string{"dx", "dy", "buttons"}},
"wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}}, "wheelReport": {Func: rpcWheelReport, Params: []string{"wheelY"}},
"getVideoState": {Func: rpcGetVideoState}, "getVideoState": {Func: rpcGetVideoState},
"getUSBState": {Func: rpcGetUSBState}, "getUSBState": {Func: rpcGetUSBState},

View File

@ -14,6 +14,7 @@ export default function InfoBar() {
const activeModifiers = useHidStore(state => state.activeModifiers); const activeModifiers = useHidStore(state => state.activeModifiers);
const mouseX = useMouseStore(state => state.mouseX); const mouseX = useMouseStore(state => state.mouseX);
const mouseY = useMouseStore(state => state.mouseY); const mouseY = useMouseStore(state => state.mouseY);
const mouseMove = useMouseStore(state => state.mouseMove);
const videoClientSize = useVideoStore( const videoClientSize = useVideoStore(
state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`, state => `${Math.round(state.clientWidth)}x${Math.round(state.clientHeight)}`,
@ -62,7 +63,7 @@ export default function InfoBar() {
</div> </div>
) : null} ) : null}
{settings.debugMode ? ( {(settings.debugMode && settings.mouseMode == "absolute") ? (
<div className="flex w-[118px] items-center gap-x-1"> <div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Pointer:</span> <span className="text-xs font-semibold">Pointer:</span>
<span className="text-xs"> <span className="text-xs">
@ -71,6 +72,17 @@ export default function InfoBar() {
</div> </div>
) : null} ) : null}
{(settings.debugMode && settings.mouseMode == "relative") ? (
<div className="flex w-[118px] items-center gap-x-1">
<span className="text-xs font-semibold">Last Move:</span>
<span className="text-xs">
{mouseMove ?
`${mouseMove.x},${mouseMove.y} ${mouseMove.buttons ? `(${mouseMove.buttons})` : ""}` :
"N/A"}
</span>
</div>
) : null}
{settings.debugMode && ( {settings.debugMode && (
<div className="flex w-[156px] items-center gap-x-1"> <div className="flex w-[156px] items-center gap-x-1">
<span className="text-xs font-semibold">USB State:</span> <span className="text-xs font-semibold">USB State:</span>

View File

@ -29,6 +29,7 @@ export default function WebRTCVideo() {
const settings = useSettingsStore(); const settings = useSettingsStore();
const { sendKeyboardEvent, resetKeyboardState } = useKeyboard(); const { sendKeyboardEvent, resetKeyboardState } = useKeyboard();
const setMousePosition = useMouseStore(state => state.setMousePosition); const setMousePosition = useMouseStore(state => state.setMousePosition);
const setMouseMove = useMouseStore(state => state.setMouseMove);
const { const {
setClientSize: setVideoClientSize, setClientSize: setVideoClientSize,
setSize: setVideoSize, setSize: setVideoSize,
@ -93,19 +94,44 @@ export default function WebRTCVideo() {
); );
// Mouse-related // Mouse-related
const sendMouseMovement = useCallback( const calcDelta = (pos: number) => Math.abs(pos) < 10 ? pos * 2 : pos;
const sendRelMouseMovement = useCallback(
(x: number, y: number, buttons: number) => { (x: number, y: number, buttons: number) => {
send("absMouseReport", { x, y, buttons }); if (settings.mouseMode !== "relative") return;
// if we ignore the event, double-click will not work
// if (x === 0 && y === 0 && buttons === 0) return;
send("relMouseReport", { dx: calcDelta(x), dy: calcDelta(y), buttons });
setMouseMove({ x, y, buttons });
},
[send, setMouseMove, settings.mouseMode],
);
const relMouseMoveHandler = useCallback(
(e: MouseEvent) => {
if (settings.mouseMode !== "relative") return;
// Send mouse movement
const { buttons } = e;
sendRelMouseMovement(e.movementX, e.movementY, buttons);
},
[sendRelMouseMovement, settings.mouseMode],
);
const sendAbsMouseMovement = useCallback(
(x: number, y: number, buttons: number) => {
if (settings.mouseMode !== "absolute") return;
send("absMouseReport", { x, y, buttons });
// We set that for the debug info bar // We set that for the debug info bar
setMousePosition(x, y); setMousePosition(x, y);
}, },
[send, setMousePosition], [send, setMousePosition, settings.mouseMode],
); );
const mouseMoveHandler = useCallback( const absMouseMoveHandler = useCallback(
(e: MouseEvent) => { (e: MouseEvent) => {
if (!videoClientWidth || !videoClientHeight) return; if (!videoClientWidth || !videoClientHeight) return;
if (settings.mouseMode !== "absolute") return;
// Get the aspect ratios of the video element and the video stream // Get the aspect ratios of the video element and the video stream
const videoElementAspectRatio = videoClientWidth / videoClientHeight; const videoElementAspectRatio = videoClientWidth / videoClientHeight;
const videoStreamAspectRatio = videoWidth / videoHeight; const videoStreamAspectRatio = videoWidth / videoHeight;
@ -140,9 +166,9 @@ export default function WebRTCVideo() {
// Send mouse movement // Send mouse movement
const { buttons } = e; const { buttons } = e;
sendMouseMovement(x, y, buttons); sendAbsMouseMovement(x, y, buttons);
}, },
[sendMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight], [sendAbsMouseMovement, videoClientHeight, videoClientWidth, videoWidth, videoHeight, settings.mouseMode],
); );
const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity); const trackpadSensitivity = useDeviceSettingsStore(state => state.trackpadSensitivity);
@ -193,8 +219,8 @@ export default function WebRTCVideo() {
); );
const resetMousePosition = useCallback(() => { const resetMousePosition = useCallback(() => {
sendMouseMovement(0, 0, 0); sendAbsMouseMovement(0, 0, 0);
}, [sendMouseMovement]); }, [sendAbsMouseMovement]);
// Keyboard-related // Keyboard-related
const handleModifierKeys = useCallback( const handleModifierKeys = useCallback(
@ -371,9 +397,9 @@ export default function WebRTCVideo() {
const abortController = new AbortController(); const abortController = new AbortController();
const signal = abortController.signal; const signal = abortController.signal;
videoElmRefValue.addEventListener("mousemove", mouseMoveHandler, { signal }); videoElmRefValue.addEventListener("mousemove", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerdown", mouseMoveHandler, { signal }); videoElmRefValue.addEventListener("pointerdown", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("pointerup", mouseMoveHandler, { signal }); videoElmRefValue.addEventListener("pointerup", absMouseMoveHandler, { signal });
videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal }); videoElmRefValue.addEventListener("keyup", videoKeyUpHandler, { signal });
videoElmRefValue.addEventListener("wheel", mouseWheelHandler, { videoElmRefValue.addEventListener("wheel", mouseWheelHandler, {
signal, signal,
@ -395,7 +421,7 @@ export default function WebRTCVideo() {
}; };
}, },
[ [
mouseMoveHandler, absMouseMoveHandler,
resetMousePosition, resetMousePosition,
onVideoPlaying, onVideoPlaying,
mouseWheelHandler, mouseWheelHandler,
@ -403,6 +429,31 @@ export default function WebRTCVideo() {
], ],
); );
useEffect(
function setupRelativeMouseEventListeners() {
if (settings.mouseMode !== "relative") return;
const abortController = new AbortController();
const signal = abortController.signal;
// bind to body to capture all mouse events
const body = document.querySelector("body");
if (!body) return;
body.addEventListener("mousemove", relMouseMoveHandler, { signal });
body.addEventListener("pointerdown", relMouseMoveHandler, { signal });
body.addEventListener("pointerup", relMouseMoveHandler, { signal });
return () => {
abortController.abort();
body.removeEventListener("mousemove", relMouseMoveHandler);
body.removeEventListener("pointerdown", relMouseMoveHandler);
body.removeEventListener("pointerup", relMouseMoveHandler);
};
}, [settings.mouseMode, relMouseMoveHandler],
)
useEffect( useEffect(
function updateVideoStream() { function updateVideoStream() {
if (!mediaStream) return; if (!mediaStream) return;

View File

@ -197,15 +197,23 @@ export const useRTCStore = create<RTCState>(set => ({
setTerminalChannel: channel => set({ terminalChannel: channel }), setTerminalChannel: channel => set({ terminalChannel: channel }),
})); }));
interface MouseMove {
x: number;
y: number;
buttons: number;
}
interface MouseState { interface MouseState {
mouseX: number; mouseX: number;
mouseY: number; mouseY: number;
mouseMove?: MouseMove;
setMouseMove: (move?: MouseMove) => void;
setMousePosition: (x: number, y: number) => void; setMousePosition: (x: number, y: number) => void;
} }
export const useMouseStore = create<MouseState>(set => ({ export const useMouseStore = create<MouseState>(set => ({
mouseX: 0, mouseX: 0,
mouseY: 0, mouseY: 0,
setMouseMove: (move?: MouseMove) => set({ mouseMove: move }),
setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }), setMousePosition: (x, y) => set({ mouseX: x, mouseY: y }),
})); }));

View File

@ -1,23 +1,27 @@
import { SettingsPageHeader } from "@components/SettingsPageheader"; import MouseIcon from "@/assets/mouse-icon.svg";
import { SettingsItem } from "./devices.$id.settings";
import { Checkbox } from "@/components/Checkbox";
import { GridCard } from "@/components/Card";
import PointingFinger from "@/assets/pointing-finger.svg"; import PointingFinger from "@/assets/pointing-finger.svg";
import { CheckCircleIcon } from "@heroicons/react/16/solid"; import { GridCard } from "@/components/Card";
import { Checkbox } from "@/components/Checkbox";
import { useDeviceSettingsStore, useSettingsStore } from "@/hooks/stores"; import { useDeviceSettingsStore, useSettingsStore } from "@/hooks/stores";
import notifications from "@/notifications";
import { useCallback, useEffect, useState } from "react";
import { useJsonRpc } from "@/hooks/useJsonRpc"; import { useJsonRpc } from "@/hooks/useJsonRpc";
import { cx } from "../cva.config"; import notifications from "@/notifications";
import { SettingsPageHeader } from "@components/SettingsPageheader";
import { CheckCircleIcon } from "@heroicons/react/16/solid";
import { useCallback, useEffect, useState } from "react";
import { FeatureFlag } from "../components/FeatureFlag";
import { SelectMenuBasic } from "../components/SelectMenuBasic"; import { SelectMenuBasic } from "../components/SelectMenuBasic";
import { useFeatureFlag } from "../hooks/useFeatureFlag"; import { useFeatureFlag } from "../hooks/useFeatureFlag";
import { FeatureFlag } from "../components/FeatureFlag"; import { SettingsItem } from "./devices.$id.settings";
type ScrollSensitivity = "low" | "default" | "high"; type ScrollSensitivity = "low" | "default" | "high";
export default function SettingsKeyboardMouseRoute() { export default function SettingsKeyboardMouseRoute() {
const hideCursor = useSettingsStore(state => state.isCursorHidden); const hideCursor = useSettingsStore(state => state.isCursorHidden);
const setHideCursor = useSettingsStore(state => state.setCursorVisibility); const setHideCursor = useSettingsStore(state => state.setCursorVisibility);
const mouseMode = useSettingsStore(state => state.mouseMode);
const setMouseMode = useSettingsStore(state => state.setMouseMode);
const scrollSensitivity = useDeviceSettingsStore(state => state.scrollSensitivity); const scrollSensitivity = useDeviceSettingsStore(state => state.scrollSensitivity);
const setScrollSensitivity = useDeviceSettingsStore( const setScrollSensitivity = useDeviceSettingsStore(
state => state.setScrollSensitivity, state => state.setScrollSensitivity,
@ -122,19 +126,19 @@ export default function SettingsKeyboardMouseRoute() {
</SettingsItem> </SettingsItem>
<div className="space-y-4"> <div className="space-y-4">
<SettingsItem title="Modes" description="Choose the mouse input mode" /> <SettingsItem title="Modes" description="Choose the mouse input mode" />
<div className="flex flex-col items-center gap-4 md:flex-row"> <div className="flex items-center gap-4">
<button <button
className="group block w-full grow" className="block group grow"
onClick={() => console.log("Absolute mouse mode clicked")} onClick={() => { setMouseMode("absolute"); }}
> >
<GridCard> <GridCard>
<div className="group flex items-center gap-x-4 px-4 py-3"> <div className="flex items-center px-4 py-3 group gap-x-4">
<img <img
className="w-6 shrink-0 dark:invert" className="w-6 shrink-0 dark:invert"
src={PointingFinger} src={PointingFinger}
alt="Finger touching a screen" alt="Finger touching a screen"
/> />
<div className="flex grow items-center justify-between"> <div className="flex items-center justify-between grow">
<div className="text-left"> <div className="text-left">
<h3 className="text-sm font-semibold text-black dark:text-white"> <h3 className="text-sm font-semibold text-black dark:text-white">
Absolute Absolute
@ -143,41 +147,32 @@ export default function SettingsKeyboardMouseRoute() {
Most convenient Most convenient
</p> </p>
</div> </div>
<CheckCircleIcon {mouseMode === "absolute" && (
className={cx( <CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
"h-4 w-4 text-blue-700 transition-opacity duration-300 dark:text-blue-500",
)} )}
/>
</div> </div>
</div> </div>
</GridCard> </GridCard>
</button> </button>
<button <button
className="group block w-full grow cursor-not-allowed opacity-50" className="block group grow"
disabled onClick={() => { setMouseMode("relative"); }}
> >
<GridCard> <GridCard>
<div className="group flex items-center gap-x-4 px-4 py-3"> <div className="flex items-center px-4 py-3 gap-x-4">
<img <img className="w-6 shrink-0 dark:invert" src={MouseIcon} alt="Mouse icon" />
className="w-6 shrink-0 dark:invert" <div className="flex items-center justify-between grow">
src={PointingFinger}
alt="Finger touching a screen"
/>
<div className="flex grow items-center justify-between">
<div className="text-left"> <div className="text-left">
<h3 className="text-sm font-semibold text-black dark:text-white"> <h3 className="text-sm font-semibold text-black dark:text-white">
Relative Relative
</h3> </h3>
<p className="text-xs leading-none text-slate-800 dark:text-slate-300"> <p className="text-xs leading-none text-slate-800 dark:text-slate-300">
Most Compatible Most Compatible (Beta)
</p> </p>
</div> </div>
<CheckCircleIcon {mouseMode === "relative" && (
className={cx( <CheckCircleIcon className="w-4 h-4 text-blue-700 dark:text-blue-500" />
"hidden",
"h-4 w-4 text-blue-700 transition-opacity duration-300 dark:text-blue-500",
)} )}
/>
</div> </div>
</div> </div>
</GridCard> </GridCard>

7
usb.go
View File

@ -1,8 +1,9 @@
package kvm package kvm
import ( import (
"github.com/jetkvm/kvm/internal/usbgadget"
"time" "time"
"github.com/jetkvm/kvm/internal/usbgadget"
) )
var gadget *usbgadget.UsbGadget var gadget *usbgadget.UsbGadget
@ -33,6 +34,10 @@ func rpcAbsMouseReport(x, y int, buttons uint8) error {
return gadget.AbsMouseReport(x, y, buttons) return gadget.AbsMouseReport(x, y, buttons)
} }
func rpcRelMouseReport(dx, dy int8, buttons uint8) error {
return gadget.RelMouseReport(dx, dy, buttons)
}
func rpcWheelReport(wheelY int8) error { func rpcWheelReport(wheelY int8) error {
return gadget.AbsMouseWheelReport(wheelY) return gadget.AbsMouseWheelReport(wheelY)
} }