From 4d9b397c87c9eb800f104edcd84294e3c57bd0b7 Mon Sep 17 00:00:00 2001 From: SudoWatson Date: Wed, 8 Jul 2026 20:50:35 -0400 Subject: [PATCH 01/23] Port Linux platform layer onto upstream main (post-PR #64 merge) Rebases linux-support onto petoncle/mousemaster main after PR #64 was reviewed and merged with structural changes. Key adaptations from the old draft to the merged interfaces: - LinuxKeyboard implements KeyboardController (was Keyboard) - LinuxMouse implements MouseController (was PlatformMouse) - LinuxClock implements Clock (was PlatformClock) - LinuxPlatform.reset() takes MouseManager (was MouseController) - KeyRegurgitator is now a plain class wrapping KeyboardController; LinuxKeyRegurgitator deleted, KeyboardLayoutProvider deleted - LinuxPlatform.activeKeyboardLayout() replaces keyboardLayoutProvider() Also carries over GridWindow, HintMeshWindow, Nix flake, and project docs from the old branch. Co-Authored-By: Claude Sonnet 4.6 --- flake.lock | 78 +++++ flake.nix | 59 ++++ mvnw | 0 .../mousemaster/platform/linux/LibX11.java | 179 +++++++++++ .../mousemaster/platform/linux/LibXRandr.java | 133 ++++++++ .../platform/linux/LinuxActiveAppFinder.java | 22 ++ .../platform/linux/LinuxClock.java | 14 + .../platform/linux/LinuxConsole.java | 17 + .../platform/linux/LinuxKeyboard.java | 55 ++++ .../linux/LinuxKeyboardSimulator.java | 66 ++++ .../mousemaster/platform/linux/LinuxMain.java | 78 +++++ .../platform/linux/LinuxMouse.java | 86 +++++ .../platform/linux/LinuxOverlay.java | 277 ++++++++++++++++ .../platform/linux/LinuxPlatform.java | 295 ++++++++++++++++++ .../platform/linux/LinuxScreens.java | 86 +++++ .../platform/linux/LinuxUiAutomation.java | 27 ++ .../mousemaster/platform/linux/X11Test.java | 85 +++++ .../mousemaster/platform/linux/X11Test2.java | 105 +++++++ src/main/java/mousemaster/qt/GridWindow.java | 94 ++++++ .../java/mousemaster/qt/HintMeshWindow.java | 116 +++++++ 20 files changed, 1872 insertions(+) create mode 100644 flake.lock create mode 100644 flake.nix mode change 100644 => 100755 mvnw create mode 100644 src/main/java/mousemaster/platform/linux/LibX11.java create mode 100644 src/main/java/mousemaster/platform/linux/LibXRandr.java create mode 100644 src/main/java/mousemaster/platform/linux/LinuxActiveAppFinder.java create mode 100644 src/main/java/mousemaster/platform/linux/LinuxClock.java create mode 100644 src/main/java/mousemaster/platform/linux/LinuxConsole.java create mode 100644 src/main/java/mousemaster/platform/linux/LinuxKeyboard.java create mode 100644 src/main/java/mousemaster/platform/linux/LinuxKeyboardSimulator.java create mode 100644 src/main/java/mousemaster/platform/linux/LinuxMain.java create mode 100644 src/main/java/mousemaster/platform/linux/LinuxMouse.java create mode 100644 src/main/java/mousemaster/platform/linux/LinuxOverlay.java create mode 100644 src/main/java/mousemaster/platform/linux/LinuxPlatform.java create mode 100644 src/main/java/mousemaster/platform/linux/LinuxScreens.java create mode 100644 src/main/java/mousemaster/platform/linux/LinuxUiAutomation.java create mode 100644 src/main/java/mousemaster/platform/linux/X11Test.java create mode 100644 src/main/java/mousemaster/platform/linux/X11Test2.java create mode 100644 src/main/java/mousemaster/qt/GridWindow.java create mode 100644 src/main/java/mousemaster/qt/HintMeshWindow.java diff --git a/flake.lock b/flake.lock new file mode 100644 index 00000000..dde0f6fe --- /dev/null +++ b/flake.lock @@ -0,0 +1,78 @@ +{ + "nodes": { + "flake-utils": { + "inputs": { + "systems": "systems" + }, + "locked": { + "lastModified": 1731533236, + "narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=", + "owner": "numtide", + "repo": "flake-utils", + "rev": "11707dc2f618dd54ca8739b309ec4fc024de578b", + "type": "github" + }, + "original": { + "owner": "numtide", + "repo": "flake-utils", + "type": "github" + } + }, + "nixpkgs": { + "locked": { + "lastModified": 1782535326, + "narHash": "sha256-ZeRxu4yn6shd3SNF5ZUQb4r7BaVo1zBKMjRhfoNSBmw=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "714a5f8c4ead6b31148d829288440ed033ccc041", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-26.05", + "repo": "nixpkgs", + "type": "github" + } + }, + "nixpkgs-qt682": { + "locked": { + "lastModified": 1738334133, + "narHash": "sha256-hR/KuYpJgLjuyeQIs7xiTNAjNd66OXiq/6L+D9nWYV8=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "86c0981230fd186dcefe317db93963c0bcdd1810", + "type": "github" + }, + "original": { + "owner": "nixos", + "repo": "nixpkgs", + "rev": "86c0981230fd186dcefe317db93963c0bcdd1810", + "type": "github" + } + }, + "root": { + "inputs": { + "flake-utils": "flake-utils", + "nixpkgs": "nixpkgs", + "nixpkgs-qt682": "nixpkgs-qt682" + } + }, + "systems": { + "locked": { + "lastModified": 1681028828, + "narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=", + "owner": "nix-systems", + "repo": "default", + "rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e", + "type": "github" + }, + "original": { + "owner": "nix-systems", + "repo": "default", + "type": "github" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 00000000..34a3a125 --- /dev/null +++ b/flake.nix @@ -0,0 +1,59 @@ +{ + description = "Development environment for Mousemaster - Linux port"; + + inputs = { + nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05"; + nixpkgs-qt682.url = "github:nixos/nixpkgs/86c0981230fd186dcefe317db93963c0bcdd1810"; + flake-utils.url = "github:numtide/flake-utils"; + }; + + outputs = { self, nixpkgs, nixpkgs-qt682, flake-utils }: + flake-utils.lib.eachDefaultSystem (system: + let + pkgs = import nixpkgs { inherit system; }; + pkgs-qt682 = import nixpkgs-qt682 { inherit system; }; + in { + devShells.default = pkgs.mkShell { + packages = with pkgs; [ + # Java development + jdk21 + maven + + # X11 libraries for JNA bindings + xorg.libX11 + xorg.libXrandr + xorg.libXtst + xorg.libxcb + + # Qt 6.8.2 libraries - pinned to nixpkgs commit before 6.8.3 to match QtJambi 6.8.2 + pkgs-qt682.qt6.full + pkgs-qt682.qt6.qtbase + + # Additional X11 dependencies + xorg.libXi + xorg.libXext + xorg.libXrender + xorg.libXfixes + + # Build tools + pkg-config + ]; + + shellHook = '' + export JAVA_HOME="${pkgs.jdk21}" + export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath [ + pkgs.xorg.libX11 + pkgs.xorg.libXrandr + pkgs.xorg.libXtst + pkgs.xorg.libxcb + pkgs-qt682.qt6.qtbase + pkgs.xorg.libXi + pkgs.xorg.libXext + ]}:$LD_LIBRARY_PATH" + echo "Mousemaster development environment loaded" + echo "Java: $(java -version 2>&1 | head -n1)" + echo "Maven: $(mvn -version | head -n1)" + ''; + }; + }); +} diff --git a/mvnw b/mvnw old mode 100644 new mode 100755 diff --git a/src/main/java/mousemaster/platform/linux/LibX11.java b/src/main/java/mousemaster/platform/linux/LibX11.java new file mode 100644 index 00000000..abee8c57 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LibX11.java @@ -0,0 +1,179 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.Structure; +import com.sun.jna.ptr.IntByReference; +import com.sun.jna.ptr.LongByReference; +import com.sun.jna.ptr.PointerByReference; + +/** + * JNA bindings for Xlib (libX11.so) + */ +public interface LibX11 extends Library { + + LibX11 INSTANCE = Native.load("X11", LibX11.class); + + // Display management + Pointer XOpenDisplay(String displayName); + int XCloseDisplay(Pointer display); + int XDefaultScreen(Pointer display); + long XDefaultRootWindow(Pointer display); + int XFlush(Pointer display); + int XSync(Pointer display, boolean discard); + + // Atom management + long XInternAtom(Pointer display, String atomName, boolean onlyIfExists); + + // Window properties + int XChangeProperty(Pointer display, long window, long property, long type, + int format, int mode, byte[] data, int nelements); + + int XGetWindowProperty(Pointer display, long window, long property, + long longOffset, long longLength, boolean delete, + long reqType, LongByReference actualTypeReturn, + IntByReference actualFormatReturn, + LongByReference nitemsReturn, + LongByReference bytesAfterReturn, + PointerByReference propReturn); + + // Mouse pointer control + int XWarpPointer(Pointer display, long srcWindow, long destWindow, + int srcX, int srcY, int srcWidth, int srcHeight, + int destX, int destY); + + boolean XQueryPointer(Pointer display, long window, + LongByReference rootReturn, LongByReference childReturn, + IntByReference rootXReturn, IntByReference rootYReturn, + IntByReference winXReturn, IntByReference winYReturn, + IntByReference maskReturn); + + // Window management + int XGetWindowAttributes(Pointer display, long window, XWindowAttributes attributesReturn); + + // Memory management + int XFree(Pointer data); + + // Event types + int KeyPress = 2; + int KeyRelease = 3; + + // Event handling + int XPending(Pointer display); + int XNextEvent(Pointer display, XEvent eventReturn); + String XKeysymToString(long keysym); + long XLookupKeysym(XKeyEvent event, int index); + + // Keyboard grabbing + int XGrabKeyboard(Pointer display, long window, int ownerEvents, + int pointerMode, int keyboardMode, long time); + void XUngrabKeyboard(Pointer display, long time); + + // Event masks + void XSelectInput(Pointer display, long window, long eventMask); + + // Grab modes + int GrabModeSync = 0; + int GrabModeAsync = 1; + + // Grab status + int GrabSuccess = 0; + int AlreadyGrabbed = 1; + int GrabInvalidTime = 2; + int GrabNotViewable = 3; + int GrabFrozen = 4; + + // Event masks + long KeyPressMask = 1L << 0; + long KeyReleaseMask = 1L << 1; + + // Time constants + long CurrentTime = 0L; + + // Constants + int PropModeReplace = 0; + long None = 0L; + long AnyPropertyType = 0L; + long XA_ATOM = 4L; + long XA_WINDOW = 33L; + + /** + * X11 Event union structure + */ + @Structure.FieldOrder({"type", "pad"}) + class XEvent extends Structure { + public int type; + public byte[] pad = new byte[192]; // XEvent is 192 bytes + + public XKeyEvent getKeyEvent() { + XKeyEvent keyEvent = new XKeyEvent(getPointer()); + keyEvent.read(); + return keyEvent; + } + } + + /** + * X11 KeyPress/KeyRelease event structure + */ + @Structure.FieldOrder({"type", "serial", "send_event", "display", "window", + "root", "subwindow", "time", "x", "y", "x_root", "y_root", + "state", "keycode", "same_screen"}) + class XKeyEvent extends Structure { + public int type; + public long serial; + public int send_event; + public Pointer display; + public long window; + public long root; + public long subwindow; + public long time; + public int x, y; + public int x_root, y_root; + public int state; + public int keycode; + public int same_screen; + + public XKeyEvent() { + super(); + } + + public XKeyEvent(Pointer p) { + super(p); + } + } + + /** + * Structure for window attributes + */ + @Structure.FieldOrder({"x", "y", "width", "height", "borderWidth", "depth", + "visual", "root", "clazz", "bitGravity", "winGravity", + "backingStore", "backingPlanes", "backingPixel", + "saveUnder", "colormap", "mapInstalled", "mapState", + "allEventMasks", "yourEventMask", "doNotPropagateMask", + "overrideRedirect", "screen"}) + class XWindowAttributes extends Structure { + public int x, y; + public int width, height; + public int borderWidth; + public int depth; + public Pointer visual; + public long root; + public int clazz; + public int bitGravity; + public int winGravity; + public int backingStore; + public long backingPlanes; + public long backingPixel; + public boolean saveUnder; + public long colormap; + public boolean mapInstalled; + public int mapState; + public long allEventMasks; + public long yourEventMask; + public long doNotPropagateMask; + public boolean overrideRedirect; + public Pointer screen; + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LibXRandr.java b/src/main/java/mousemaster/platform/linux/LibXRandr.java new file mode 100644 index 00000000..57bfb0ad --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LibXRandr.java @@ -0,0 +1,133 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; +import com.sun.jna.Structure; + +/** + * JNA bindings for XRandR extension (libXrandr.so) + */ +public interface LibXRandr extends Library { + + LibXRandr INSTANCE = Native.load("Xrandr", LibXRandr.class); + + // Screen resources + Pointer XRRGetScreenResources(Pointer display, long window); + void XRRFreeScreenResources(Pointer resources); + + // Monitor information (XRandR 1.5+) + Pointer XRRGetMonitors(Pointer display, long window, boolean getActive, IntByReference nmonitors); + void XRRFreeMonitors(Pointer monitors); + + // CRTC information + Pointer XRRGetCrtcInfo(Pointer display, Pointer resources, long crtc); + void XRRFreeCrtcInfo(Pointer crtcInfo); + + // Output information + Pointer XRRGetOutputInfo(Pointer display, Pointer resources, long output); + void XRRFreeOutputInfo(Pointer outputInfo); + + /** + * Helper class for reading int by reference + */ + class IntByReference extends com.sun.jna.ptr.IntByReference { + public IntByReference() { + super(); + } + public IntByReference(int value) { + super(value); + } + } + + /** + * XRRMonitorInfo structure (XRandR 1.5+) + */ + @Structure.FieldOrder({"name", "primary", "automatic", "noutput", "x", "y", "width", "height", + "mwidth", "mheight", "outputs"}) + class XRRMonitorInfo extends Structure { + public long name; // Atom + public boolean primary; + public boolean automatic; + public int noutput; + public int x, y; + public int width, height; // pixels + public int mwidth, mheight; // millimeters + public Pointer outputs; // RROutput* + + public XRRMonitorInfo() { + super(); + } + + public XRRMonitorInfo(Pointer p) { + super(p); + read(); + } + + public static class ByReference extends XRRMonitorInfo implements Structure.ByReference {} + } + + /** + * XRRScreenResources structure + */ + @Structure.FieldOrder({"timestamp", "configTimestamp", "ncrtc", "crtcs", "noutput", "outputs", + "nmode", "modes"}) + class XRRScreenResources extends Structure { + public long timestamp; + public long configTimestamp; + public int ncrtc; + public Pointer crtcs; // RRCrtc* + public int noutput; + public Pointer outputs; // RROutput* + public int nmode; + public Pointer modes; // XRRModeInfo* + + public static class ByReference extends XRRScreenResources implements Structure.ByReference {} + } + + /** + * XRRCrtcInfo structure + */ + @Structure.FieldOrder({"timestamp", "x", "y", "width", "height", "mode", "rotation", + "noutput", "outputs", "rotations", "npossible", "possible"}) + class XRRCrtcInfo extends Structure { + public long timestamp; + public int x, y; + public int width, height; + public long mode; // RRMode + public short rotation; + public int noutput; + public Pointer outputs; // RROutput* + public short rotations; + public int npossible; + public Pointer possible; // RROutput* + + public static class ByReference extends XRRCrtcInfo implements Structure.ByReference {} + } + + /** + * XRROutputInfo structure + */ + @Structure.FieldOrder({"timestamp", "crtc", "name", "nameLen", "mmWidth", "mmHeight", + "connection", "subpixelOrder", "ncrtc", "crtcs", "nclone", "clones", + "nmode", "npreferred", "modes"}) + class XRROutputInfo extends Structure { + public long timestamp; + public long crtc; // RRCrtc + public Pointer name; // char* + public int nameLen; + public long mmWidth, mmHeight; // millimeters + public byte connection; + public byte subpixelOrder; + public int ncrtc; + public Pointer crtcs; // RRCrtc* + public int nclone; + public Pointer clones; // RROutput* + public int nmode; + public int npreferred; + public Pointer modes; // RRMode* + + public static class ByReference extends XRROutputInfo implements Structure.ByReference {} + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxActiveAppFinder.java b/src/main/java/mousemaster/platform/linux/LinuxActiveAppFinder.java new file mode 100644 index 00000000..35a984dc --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxActiveAppFinder.java @@ -0,0 +1,22 @@ +package mousemaster.platform.linux; + +import mousemaster.App; +import mousemaster.platform.ActiveAppFinder; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Stub implementation of ActiveAppFinder for Milestone 1. + * TODO: Implement using XGetInputFocus + _NET_WM_PID + /proc for Milestone 3. + */ +public class LinuxActiveAppFinder implements ActiveAppFinder { + + private static final Logger logger = LoggerFactory.getLogger(LinuxActiveAppFinder.class); + + @Override + public App activeApp() { + // TODO: Get active window via XGetInputFocus and read process name from /proc + return new App("unknown"); + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxClock.java b/src/main/java/mousemaster/platform/linux/LinuxClock.java new file mode 100644 index 00000000..8ff9ab32 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxClock.java @@ -0,0 +1,14 @@ +package mousemaster.platform.linux; + +import mousemaster.Clock; + +import java.time.Instant; + +public class LinuxClock implements Clock { + + @Override + public Instant now() { + return Instant.now(); + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxConsole.java b/src/main/java/mousemaster/platform/linux/LinuxConsole.java new file mode 100644 index 00000000..c5179f16 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxConsole.java @@ -0,0 +1,17 @@ +package mousemaster.platform.linux; + +import mousemaster.platform.Console; + +public class LinuxConsole implements Console { + + @Override + public void show() { + // No-op on Linux - app runs in terminal or as daemon + } + + @Override + public void hide() { + // No-op on Linux - app runs in terminal or as daemon + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxKeyboard.java b/src/main/java/mousemaster/platform/linux/LinuxKeyboard.java new file mode 100644 index 00000000..903daa64 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxKeyboard.java @@ -0,0 +1,55 @@ +package mousemaster.platform.linux; + +import mousemaster.Key; +import mousemaster.ResolvedMacroMove; +import mousemaster.platform.KeyboardController; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +/** + * Stub implementation of KeyboardController for Milestone 1. + * TODO: Implement X11 keyboard event reading and injection for Milestone 2. + */ +public class LinuxKeyboard implements KeyboardController { + + private static final Logger logger = LoggerFactory.getLogger(LinuxKeyboard.class); + + @Override + public void update(double delta) { + // TODO: Process keyboard events from X11 + } + + @Override + public void reset() { + // TODO: Reset keyboard state + } + + @Override + public void sendInputMoves(List moves, boolean startRepeat) { + // TODO: Send keyboard input via XTest + logger.debug("sendInputMoves() called with {} moves", moves.size()); + } + + @Override + public void keyPressedNotEaten(Key key) { + // TODO: Handle key press that wasn't consumed + } + + @Override + public void keyReleasedNotEaten(Key key) { + // TODO: Handle key release that wasn't consumed + } + + @Override + public void recordEarlyReleaseForQueuedPress(Key key) { + // TODO: Track early releases + } + + @Override + public void clearEarlyReleaseForQueuedPress(Key key) { + // TODO: Clear early release tracking + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxKeyboardSimulator.java b/src/main/java/mousemaster/platform/linux/LinuxKeyboardSimulator.java new file mode 100644 index 00000000..f409f54d --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxKeyboardSimulator.java @@ -0,0 +1,66 @@ +package mousemaster.platform.linux; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import java.util.Scanner; +import java.util.concurrent.BlockingQueue; +import java.util.concurrent.LinkedBlockingQueue; + +/** + * Temporary keyboard input simulator for testing on Wayland. + * Reads keyboard input from stdin in a separate thread. + * This is a workaround until proper evdev support is implemented. + */ +public class LinuxKeyboardSimulator { + private static final Logger logger = LoggerFactory.getLogger(LinuxKeyboardSimulator.class); + + private final BlockingQueue keyQueue = new LinkedBlockingQueue<>(); + private Thread inputThread; + private volatile boolean running = false; + + public void start() { + if (running) return; + + running = true; + inputThread = new Thread(this::readInput, "KeyboardSimulator"); + inputThread.setDaemon(true); + inputThread.start(); + + logger.info("Keyboard simulator started (reading from stdin)"); + logger.info("Type single letters and press Enter to simulate keypresses"); + } + + public void stop() { + running = false; + if (inputThread != null) { + inputThread.interrupt(); + } + } + + private void readInput() { + try (Scanner scanner = new Scanner(System.in)) { + while (running) { + if (scanner.hasNextLine()) { + String line = scanner.nextLine().trim(); + if (!line.isEmpty()) { + for (char c : line.toCharArray()) { + String key = String.valueOf(c); + keyQueue.offer(key.toLowerCase()); + logger.debug("Queued key: {}", key); + } + } + } + } + } catch (Exception e) { + logger.error("Error reading keyboard input", e); + } + } + + public String pollKey() { + return keyQueue.poll(); + } + + public boolean hasKeys() { + return !keyQueue.isEmpty(); + } +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxMain.java b/src/main/java/mousemaster/platform/linux/LinuxMain.java new file mode 100644 index 00000000..a6aa6b92 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxMain.java @@ -0,0 +1,78 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Native; +import mousemaster.ApplicationOptions; +import mousemaster.Mousemaster; +import mousemaster.MousemasterApplication; +import mousemaster.Platform; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Properties; + +public class LinuxMain { + + private static final Logger logger = LoggerFactory.getLogger(LinuxMain.class); + + public static void main(String[] args) throws InterruptedException, IOException { + ApplicationOptions options = ApplicationOptions.parse(args); + MousemasterApplication.setTempDirectory(options.tempDirectory()); + if (options.logLevel() != null) + MousemasterApplication.setLogLevel(options.logLevel()); + if (options.logToFile()) + MousemasterApplication.enableLogToFile(); + String version; + String commitId; + try (InputStream versionInputStream = LinuxMain.class.getClassLoader() + .getResourceAsStream( + "application.properties")) { + Properties versionProp = new Properties(); + versionProp.load(versionInputStream); + version = versionProp.getProperty("version"); + commitId = versionProp.getProperty("commitId"); + } + if (options.showVersion()) { + System.out.println("mousemaster v" + version + " (" + commitId + ")"); + return; + } + if (options.graalvmAgentRun()) { + logger.info("--graalvm-agent-run flag found, exiting in 20s"); + new Thread(() -> { + try { + Thread.sleep(20000); + } catch (InterruptedException e) { + throw new RuntimeException(e); + } + System.exit(0); + }).start(); + } + Platform platform = createPlatform(options.multipleInstancesAllowed(), + options.keyRegurgitationEnabled(), options.pauseOnError()); + logger.info("mousemaster v" + version + " (" + commitId + ") [Linux]"); + if (platform == null) + return; + try { + Native.setCallbackExceptionHandler((c, e) -> + MousemasterApplication.shutdownAfterException(e, platform, true, + options.pauseOnError())); + new Mousemaster(options.configurationPath(), platform).run(); + } catch (Throwable e) { + MousemasterApplication.shutdownAfterException(e, platform, false, + options.pauseOnError()); + } + } + + private static Platform createPlatform(boolean multipleInstancesAllowed, + boolean keyRegurgitationEnabled, + boolean pauseOnError) { + try { + return new LinuxPlatform(multipleInstancesAllowed, keyRegurgitationEnabled); + } catch (Exception e) { + MousemasterApplication.shutdownAfterException(e, null, false, pauseOnError); + } + return null; + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxMouse.java b/src/main/java/mousemaster/platform/linux/LinuxMouse.java new file mode 100644 index 00000000..2dc1628f --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxMouse.java @@ -0,0 +1,86 @@ +package mousemaster.platform.linux; + +import mousemaster.platform.MouseController; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * Stub implementation of MouseController for Milestone 1. + * TODO: Implement X11 mouse control via XWarpPointer and XTest for Milestone 3. + */ +public class LinuxMouse implements MouseController { + + private static final Logger logger = LoggerFactory.getLogger(LinuxMouse.class); + + @Override + public void beginMove() { + // TODO: Begin mouse movement batch + } + + @Override + public void endMove() { + // TODO: End mouse movement batch and flush + } + + @Override + public void moveBy(boolean xForward, double dx, boolean yForward, double dy) { + // TODO: Relative mouse movement + } + + @Override + public void synchronousMoveTo(int x, int y) { + // TODO: Absolute mouse movement via XWarpPointer + logger.debug("synchronousMoveTo({}, {}) - not yet implemented", x, y); + } + + @Override + public void pressLeft() { + // TODO: Left mouse button press via XTest + } + + @Override + public void pressMiddle() { + // TODO: Middle mouse button press via XTest + } + + @Override + public void pressRight() { + // TODO: Right mouse button press via XTest + } + + @Override + public void releaseLeft() { + // TODO: Left mouse button release via XTest + } + + @Override + public void releaseMiddle() { + // TODO: Middle mouse button release via XTest + } + + @Override + public void releaseRight() { + // TODO: Right mouse button release via XTest + } + + @Override + public void wheelHorizontallyBy(boolean forward, double delta) { + // TODO: Horizontal wheel scroll via XTest + } + + @Override + public void wheelVerticallyBy(boolean forward, double delta) { + // TODO: Vertical wheel scroll via XTest + } + + @Override + public void showCursor() { + // TODO: Show cursor (if hidden) + } + + @Override + public void hideCursor() { + // TODO: Hide cursor + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxOverlay.java b/src/main/java/mousemaster/platform/linux/LinuxOverlay.java new file mode 100644 index 00000000..f60382b7 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxOverlay.java @@ -0,0 +1,277 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Pointer; +import mousemaster.*; +import mousemaster.platform.Overlay; +import mousemaster.qt.GridWindow; +import mousemaster.qt.HintMeshWindow; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.time.Duration; +import java.util.Set; + +/** + * Linux implementation of the Overlay interface. + * Currently implements basic grid display for Milestone 1. + * TODO: Full implementation of zoom, hint mesh, and indicator features. + */ +public class LinuxOverlay implements Overlay { + + private static final Logger logger = LoggerFactory.getLogger(LinuxOverlay.class); + + private final Pointer display; + private Grid currentGrid; + private boolean showingGrid = false; + private Runnable messagePump; + private GridWindow gridWindow; + private HintMeshWindow hintMeshWindow; + private HintMesh currentHintMesh; + private LinuxPlatform platform; + + // TEMPORARY: Test mode to auto-display hints after 1 second + private double elapsedTime = 0.0; + private boolean testGridShown = false; + private boolean testGridHidden = false; + + public LinuxOverlay(Pointer display) { + this.display = display; + logger.info("LinuxOverlay initialized"); + } + + public void setPlatform(LinuxPlatform platform) { + this.platform = platform; + } + + @Override + public void update(double delta) { + elapsedTime += delta; + + // TEMPORARY: Auto-display test grid after 1 second + if (!testGridShown && elapsedTime >= 1.0) { + logger.info("TEST MODE: Auto-displaying grid after 1 second"); + displayTestGrid(); + testGridShown = true; + } + + // TEMPORARY: Auto-hide test hints after 6 seconds (5 seconds after showing) + if (testGridShown && !testGridHidden && elapsedTime >= 6.0) { + logger.info("TEST MODE: Auto-hiding hints after 5 seconds of display"); + hideHintMesh(); + testGridHidden = true; + } + } + + // TEMPORARY: Test method to display hint mesh without keyboard input + private void displayTestGrid() { + java.util.List hints = java.util.List.of( + new Hint(400, 300, 100, 100, java.util.List.of(new Key(null, null, "A"))), + new Hint(800, 300, 100, 100, java.util.List.of(new Key(null, null, "B"))), + new Hint(1200, 300, 100, 100, java.util.List.of(new Key(null, null, "C"))), + new Hint(400, 700, 100, 100, java.util.List.of(new Key(null, null, "D"))), + new Hint(800, 700, 100, 100, java.util.List.of(new Key(null, null, "E"))), + new Hint(1200, 700, 100, 100, java.util.List.of(new Key(null, null, "F"))) + ); + + HintMesh testHintMesh = new HintMesh( + true, + hints, + 0, + java.util.List.of(), + null, + null + ); + + setHintMesh(testHintMesh, null); + logger.info("TEST MODE: Hint mesh with letters A-F should now be visible on screen"); + + if (platform != null) { + platform.grabKeyboard(); + } + } + + @Override + public void flushCache() { + // TODO: Implement cache flushing if needed + } + + @Override + public void setTopmost() { + logger.debug("setTopmost() called - TODO: implement X11 always-on-top"); + } + + @Override + public void setMessagePump(Runnable pump) { + this.messagePump = pump; + } + + @Override + public void preWarmFontStyles(Set configs) { + logger.debug("preWarmFontStyles() called with {} configs", configs.size()); + } + + @Override + public void preWarmHintMeshWindows() { + logger.debug("preWarmHintMeshWindows() called"); + } + + @Override + public Rectangle activeWindowRectangle(double widthPct, double heightPct, + int topInset, int bottomInset, + int leftInset, int rightInset) { + // TODO: Get active window rectangle using X11 XGetInputFocus + XGetWindowAttributes + logger.debug("activeWindowRectangle() called - returning dummy rectangle"); + return new Rectangle(0, 0, 1920, 1080); + } + + @Override + public void setIndicator(Indicator indicator, boolean fadeAnimationEnabled, + Duration fadeAnimationDuration, boolean allowFade) { + logger.debug("setIndicator() called"); + } + + @Override + public void hideIndicator(boolean allowFade) { + // Called every frame when no indicator is active - this is normal + } + + @Override + public void setGrid(Grid grid) { + logger.info("setGrid() called: columns={}, rows={}", grid.columnCount(), grid.rowCount()); + this.currentGrid = grid; + this.showingGrid = true; + + if (gridWindow == null) { + gridWindow = new GridWindow(); + logger.debug("Created new GridWindow"); + } + + gridWindow.setGrid(grid); + logger.info("Grid displayed: {}x{} at ({},{}), size {}x{}", + grid.columnCount(), grid.rowCount(), + grid.x(), grid.y(), grid.width(), grid.height()); + } + + @Override + public void hideGrid() { + logger.debug("hideGrid() called"); + this.showingGrid = false; + this.currentGrid = null; + + if (gridWindow != null) { + gridWindow.clearGrid(); + logger.debug("Grid window hidden"); + } + } + + @Override + public void setHintMesh(HintMesh hintMesh, Zoom zoom) { + logger.debug("setHintMesh() called with {} hints", hintMesh.hints().size()); + + this.currentHintMesh = hintMesh; + + if (hintMeshWindow == null) { + hintMeshWindow = new HintMeshWindow(); + logger.debug("Created new HintMeshWindow"); + } + + hintMeshWindow.setHintMesh(hintMesh); + logger.info("Hint mesh displayed with {} hints", hintMesh.hints().size()); + + if (platform != null) { + platform.grabKeyboard(); + } + } + + @Override + public void setHintMesh(HintMesh hintMesh, Zoom zoom, boolean hintMatch) { + logger.debug("setHintMesh() called with hintMatch={}", hintMatch); + setHintMesh(hintMesh, zoom); + } + + @Override + public void hideHintMesh() { + logger.debug("hideHintMesh() called"); + + this.currentHintMesh = null; + + if (hintMeshWindow != null) { + hintMeshWindow.clearHints(); + logger.debug("Hint mesh window hidden"); + } + + if (platform != null) { + platform.ungrabKeyboard(); + } + } + + /** + * TEMPORARY: Test method to handle keypresses while hints are showing. + * In full implementation, this would go through KeyboardManager. + */ + public void handleKeyPress(String keyString) { + if (currentHintMesh == null || !currentHintMesh.visible()) { + return; + } + + String key = keyString.toUpperCase(); + + for (Hint hint : currentHintMesh.hints()) { + String hintLabel = getHintLabel(hint); + if (hintLabel.equals(key)) { + logger.info("TEST: Hint '{}' selected at position ({}, {})", + key, (int)hint.centerX(), (int)hint.centerY()); + hideHintMesh(); + return; + } + } + + logger.debug("Key '{}' pressed but doesn't match any hint", key); + } + + private String getHintLabel(Hint hint) { + if (hint.keySequence().isEmpty()) { + return ""; + } + Key key = hint.keySequence().get(0); + if (key.character() != null) { + return key.character().toUpperCase(); + } + return ""; + } + + @Override + public void animateHintMatch(Hint hint) { + logger.debug("animateHintMatch() called"); + } + + @Override + public void setZoom(Zoom zoom) { + logger.debug("setZoom() called"); + } + + @Override + public void startScreenshotZoomAnimation(Rectangle screenRect, Zoom beginZoom) { + logger.debug("startScreenshotZoomAnimation() called"); + } + + @Override + public void updateScreenshotZoom(Zoom zoom) { + logger.debug("updateScreenshotZoom() called"); + } + + @Override + public void endScreenshotZoomAnimation(Zoom finalZoom) { + logger.debug("endScreenshotZoomAnimation() called"); + } + + @Override + public boolean waitForZoomBeforeRepainting() { + return false; + } + + @Override + public void setWaitForZoomBeforeRepainting(boolean value) { + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxPlatform.java b/src/main/java/mousemaster/platform/linux/LinuxPlatform.java new file mode 100644 index 00000000..56390398 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxPlatform.java @@ -0,0 +1,295 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Pointer; +import mousemaster.*; +import mousemaster.platform.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.List; + +/** + * Linux platform implementation. + * Milestone 1: Basic structure with stubs - focuses on grid display. + * TODO: Implement full keyboard/mouse hooks for Milestones 2-3. + */ +public class LinuxPlatform implements Platform { + + private static final Logger logger = LoggerFactory.getLogger(LinuxPlatform.class); + + private final Pointer display; + private final LinuxClock clock; + private final LinuxKeyboard keyboard; + private final LinuxMouse mouse; + private final LinuxScreens screens; + private final LinuxOverlay overlay; + private final LinuxUiAutomation uiAutomation; + private final LinuxActiveAppFinder activeAppFinder; + private final LinuxConsole console; + private final KeyRegurgitator keyRegurgitator; + + private KeyboardLayout activeKeyboardLayout; + private MouseManager mouseManager; + private KeyboardManager keyboardManager; + private List mousePositionListeners; + private ModeMap modeMap; + private long rootWindow; + private boolean keyboardGrabbed = false; + private final boolean isWayland; + private LinuxKeyboardSimulator keyboardSimulator; + + public LinuxPlatform(boolean multipleInstancesAllowed, boolean keyRegurgitationEnabled) { + logger.info("Initializing LinuxPlatform"); + + // Check if running under Wayland + String sessionType = System.getenv("XDG_SESSION_TYPE"); + String waylandDisplay = System.getenv("WAYLAND_DISPLAY"); + isWayland = "wayland".equals(sessionType) || waylandDisplay != null; + + if (isWayland) { + logger.warn("Running under Wayland - keyboard grabbing will use simulator mode"); + logger.warn("For production use, evdev-based input handling is required"); + keyboardSimulator = new LinuxKeyboardSimulator(); + keyboardSimulator.start(); + } + + // Open X11 display connection (works even under XWayland for rendering) + display = LibX11.INSTANCE.XOpenDisplay(null); + if (display == null) { + throw new IllegalStateException("Unable to open X11 display - is DISPLAY environment variable set?"); + } + logger.info("X11 display opened successfully (Wayland={})", isWayland); + + // Initialize all platform components + clock = new LinuxClock(); + keyboard = new LinuxKeyboard(); + mouse = new LinuxMouse(); + screens = new LinuxScreens(display); + overlay = new LinuxOverlay(display); + overlay.setPlatform(this); + uiAutomation = new LinuxUiAutomation(); + activeAppFinder = new LinuxActiveAppFinder(); + console = new LinuxConsole(); + keyRegurgitator = new KeyRegurgitator(keyboard); + + logger.info("LinuxPlatform initialized successfully"); + + // Get root window for event monitoring + rootWindow = LibX11.INSTANCE.XDefaultRootWindow(display); + logger.info("Root window handle: {}", rootWindow); + + if (!isWayland) { + LibX11.INSTANCE.XSelectInput(display, rootWindow, + LibX11.KeyPressMask | LibX11.KeyReleaseMask); + logger.info("XSelectInput called with mask: {}", + (LibX11.KeyPressMask | LibX11.KeyReleaseMask)); + LibX11.INSTANCE.XFlush(display); + logger.info("Keyboard event monitoring setup on root window"); + } + + // Default to US QWERTY layout until XKB detection is implemented (Milestone 3) + activeKeyboardLayout = KeyboardLayout.keyboardLayoutByIdentifier.get("00000409"); + if (activeKeyboardLayout == null && !KeyboardLayout.keyboardLayoutByIdentifier.isEmpty()) { + activeKeyboardLayout = KeyboardLayout.keyboardLayoutByIdentifier.values().iterator().next(); + logger.warn("US layout not found, using fallback: {}", activeKeyboardLayout.identifier()); + } + } + + @Override + public void update(double delta) { + pumpEvents(); + overlay.update(delta); + } + + @Override + public void pumpEvents() { + if (isWayland && keyboardSimulator != null && keyboardSimulator.hasKeys()) { + String key = keyboardSimulator.pollKey(); + while (key != null) { + logger.info("Simulated keypress: {}", key); + overlay.handleKeyPress(key); + key = keyboardSimulator.pollKey(); + } + } + + int pending = LibX11.INSTANCE.XPending(display); + if (pending > 0 && !isWayland) { + logger.debug("X11 events pending: {}", pending); + } + + int eventCount = 0; + while (LibX11.INSTANCE.XPending(display) > 0) { + LibX11.XEvent event = new LibX11.XEvent(); + LibX11.INSTANCE.XNextEvent(display, event); + eventCount++; + + if (!isWayland) { + if (event.type == LibX11.KeyPress) { + LibX11.XKeyEvent keyEvent = event.getKeyEvent(); + long keysym = LibX11.INSTANCE.XLookupKeysym(keyEvent, 0); + String keyString = LibX11.INSTANCE.XKeysymToString(keysym); + + logger.info("KeyPress detected: {} (keycode: {}, state: {}, window: {})", + keyString, keyEvent.keycode, keyEvent.state, keyEvent.window); + + if (keyString != null) { + overlay.handleKeyPress(keyString); + } + } else if (event.type == LibX11.KeyRelease) { + LibX11.XKeyEvent keyEvent = event.getKeyEvent(); + long keysym = LibX11.INSTANCE.XLookupKeysym(keyEvent, 0); + String keyString = LibX11.INSTANCE.XKeysymToString(keysym); + logger.debug("KeyRelease: {} (keycode: {}, window: {})", + keyString, keyEvent.keycode, keyEvent.window); + } + } + } + + if (eventCount > 0 && !isWayland) { + logger.trace("Processed {} X11 events total", eventCount); + } + } + + public void grabKeyboard() { + if (isWayland) { + logger.info("Running on Wayland - using keyboard simulator instead of X11 grab"); + logger.info("Type letters in the terminal and press Enter to simulate keypresses"); + keyboardGrabbed = true; + return; + } + + if (!keyboardGrabbed) { + logger.info("Attempting to grab keyboard on root window: {}", rootWindow); + LibX11.INSTANCE.XFlush(display); + + int result = LibX11.INSTANCE.XGrabKeyboard(display, rootWindow, 1, + LibX11.GrabModeAsync, LibX11.GrabModeAsync, LibX11.CurrentTime); + + LibX11.INSTANCE.XFlush(display); + logger.info("XGrabKeyboard returned: {}", result); + + if (result == LibX11.GrabSuccess) { + keyboardGrabbed = true; + logger.info("Keyboard grabbed successfully on window {}", rootWindow); + pumpEvents(); + } else { + String errorMsg = switch (result) { + case 1 -> "AlreadyGrabbed"; + case 2 -> "GrabInvalidTime"; + case 3 -> "GrabNotViewable"; + case 4 -> "GrabFrozen"; + default -> "Unknown error " + result; + }; + logger.error("Failed to grab keyboard: {} (code: {})", errorMsg, result); + } + } else { + logger.debug("Keyboard already grabbed"); + } + } + + public void ungrabKeyboard() { + if (keyboardGrabbed) { + if (!isWayland) { + LibX11.INSTANCE.XUngrabKeyboard(display, LibX11.CurrentTime); + LibX11.INSTANCE.XFlush(display); + } + keyboardGrabbed = false; + logger.info("Keyboard ungrabbed"); + } + } + + @Override + public void sleep() throws InterruptedException { + pumpEvents(); + Thread.sleep(10); + pumpEvents(); + } + + @Override + public void reset(MouseManager mouseManager, KeyboardManager keyboardManager, + ModeMap modeMap, List mousePositionListeners, + KeyboardLayout activeKeyboardLayout) { + logger.debug("reset() called"); + this.mouseManager = mouseManager; + this.keyboardManager = keyboardManager; + this.mousePositionListeners = mousePositionListeners; + this.modeMap = modeMap; + this.activeKeyboardLayout = activeKeyboardLayout; + + overlay.setMessagePump(this::pumpEvents); + } + + @Override + public void shutdown() { + logger.info("Shutting down LinuxPlatform"); + + if (keyboardSimulator != null) { + keyboardSimulator.stop(); + } + + if (display != null) { + LibX11.INSTANCE.XCloseDisplay(display); + logger.info("X11 display closed"); + } + } + + @Override + public KeyRegurgitator keyRegurgitator() { + return keyRegurgitator; + } + + @Override + public Clock clock() { + return clock; + } + + @Override + public KeyboardLayout activeKeyboardLayout() { + return activeKeyboardLayout; + } + + @Override + public KeyboardController keyboard() { + return keyboard; + } + + @Override + public mousemaster.platform.MouseController mouse() { + return mouse; + } + + @Override + public Screens screens() { + return screens; + } + + @Override + public Overlay overlay() { + return overlay; + } + + @Override + public UiAutomation uiAutomation() { + return uiAutomation; + } + + @Override + public ActiveAppFinder activeAppFinder() { + return activeAppFinder; + } + + @Override + public Console console() { + return console; + } + + @Override + public void modeChanged(Mode newMode) { + logger.debug("Mode changed to: {}", newMode.name()); + } + + @Override + public void modeTimedOut() { + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxScreens.java b/src/main/java/mousemaster/platform/linux/LinuxScreens.java new file mode 100644 index 00000000..2ecb91cf --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxScreens.java @@ -0,0 +1,86 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Pointer; +import mousemaster.Rectangle; +import mousemaster.Screen; +import mousemaster.platform.Screens; +import mousemaster.platform.linux.LibXRandr.IntByReference; +import mousemaster.platform.linux.LibXRandr.XRRMonitorInfo; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.HashSet; +import java.util.Set; + +public class LinuxScreens implements Screens { + + private static final Logger logger = LoggerFactory.getLogger(LinuxScreens.class); + private final Pointer display; + + public LinuxScreens(Pointer display) { + this.display = display; + } + + @Override + public Set findScreens() { + Set screens = new HashSet<>(); + + long rootWindow = LibX11.INSTANCE.XDefaultRootWindow(display); + IntByReference nmonitorsRef = new IntByReference(0); + + Pointer monitorsPtr = LibXRandr.INSTANCE.XRRGetMonitors(display, rootWindow, true, nmonitorsRef); + + if (monitorsPtr == null) { + logger.warn("XRRGetMonitors returned null - no monitors detected"); + return screens; + } + + int nmonitors = nmonitorsRef.getValue(); + logger.debug("Detected {} monitor(s)", nmonitors); + + XRRMonitorInfo monitorInfo = new XRRMonitorInfo(monitorsPtr); + XRRMonitorInfo[] monitorArray = (XRRMonitorInfo[]) monitorInfo.toArray(nmonitors); + + for (int i = 0; i < nmonitors; i++) { + XRRMonitorInfo monitor = monitorArray[i]; + + Rectangle rectangle = new Rectangle( + monitor.x, + monitor.y, + monitor.width, + monitor.height + ); + + int dpi = calculateDpi(monitor.width, monitor.mwidth); + double scale = dpi / 96.0; + + logger.debug("Monitor {}: {}x{} at ({},{}), {}mm x {}mm, DPI={}, scale={}", + i, monitor.width, monitor.height, monitor.x, monitor.y, + monitor.mwidth, monitor.mheight, dpi, scale); + + screens.add(new Screen(rectangle, dpi, scale)); + } + + LibXRandr.INSTANCE.XRRFreeMonitors(monitorsPtr); + + return screens; + } + + private int calculateDpi(int pixels, int millimeters) { + if (millimeters <= 0) { + logger.warn("Invalid physical dimension ({}mm), using default 96 DPI", millimeters); + return 96; + } + + double inches = millimeters / 25.4; + int dpi = (int) Math.round(pixels / inches); + + if (dpi < 50 || dpi > 500) { + logger.warn("Calculated DPI {} is out of reasonable range, using 96 DPI", dpi); + return 96; + } + + return dpi; + } + +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxUiAutomation.java b/src/main/java/mousemaster/platform/linux/LinuxUiAutomation.java new file mode 100644 index 00000000..e2c2034b --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxUiAutomation.java @@ -0,0 +1,27 @@ +package mousemaster.platform.linux; + +import mousemaster.platform.UiAutomation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Collections; +import java.util.List; +import java.util.concurrent.CompletableFuture; +import java.util.concurrent.Future; + +/** + * Stub implementation of UiAutomation for Milestone 1. + * TODO: Implement using AT-SPI2 via D-Bus for Milestone 4. + */ +public class LinuxUiAutomation implements UiAutomation { + + private static final Logger logger = LoggerFactory.getLogger(LinuxUiAutomation.class); + + @Override + public Future> startFindInteractiveUiElements() { + // TODO: Query AT-SPI2 for accessible UI elements via D-Bus + logger.debug("startFindInteractiveUiElements() called - returning empty list"); + return CompletableFuture.completedFuture(Collections.emptyList()); + } + +} diff --git a/src/main/java/mousemaster/platform/linux/X11Test.java b/src/main/java/mousemaster/platform/linux/X11Test.java new file mode 100644 index 00000000..4457fc5b --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/X11Test.java @@ -0,0 +1,85 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Pointer; + +/** + * Simple test to verify X11 event handling is working + */ +public class X11Test { + public static void main(String[] args) throws InterruptedException { + System.out.println("X11 Event Test - Press keys to test detection..."); + + Pointer display = LibX11.INSTANCE.XOpenDisplay(null); + if (display == null) { + System.err.println("Failed to open X11 display"); + return; + } + System.out.println("Display opened successfully"); + + long rootWindow = LibX11.INSTANCE.XDefaultRootWindow(display); + System.out.println("Root window: " + rootWindow); + + LibX11.INSTANCE.XSelectInput(display, rootWindow, + LibX11.KeyPressMask | LibX11.KeyReleaseMask); + System.out.println("XSelectInput called with mask: " + + (LibX11.KeyPressMask | LibX11.KeyReleaseMask)); + + LibX11.INSTANCE.XFlush(display); + + System.out.println("\nAttempting to grab keyboard..."); + int grabResult = LibX11.INSTANCE.XGrabKeyboard(display, rootWindow, 1, + LibX11.GrabModeAsync, LibX11.GrabModeAsync, LibX11.CurrentTime); + System.out.println("XGrabKeyboard result: " + grabResult); + + if (grabResult == LibX11.GrabSuccess) { + System.out.println("Keyboard grabbed successfully!"); + } else { + System.out.println("Failed to grab keyboard: " + grabResult); + } + + LibX11.INSTANCE.XFlush(display); + + System.out.println("\nListening for events for 10 seconds..."); + long startTime = System.currentTimeMillis(); + int totalEvents = 0; + + while (System.currentTimeMillis() - startTime < 10000) { + int pending = LibX11.INSTANCE.XPending(display); + if (pending > 0) { + System.out.println("Events pending: " + pending); + } + + while (LibX11.INSTANCE.XPending(display) > 0) { + LibX11.XEvent event = new LibX11.XEvent(); + LibX11.INSTANCE.XNextEvent(display, event); + totalEvents++; + + System.out.println("Event type: " + event.type); + + if (event.type == LibX11.KeyPress) { + LibX11.XKeyEvent keyEvent = event.getKeyEvent(); + long keysym = LibX11.INSTANCE.XLookupKeysym(keyEvent, 0); + String keyString = LibX11.INSTANCE.XKeysymToString(keysym); + System.out.println("KeyPress: " + keyString + " (keycode: " + keyEvent.keycode + ")"); + } else if (event.type == LibX11.KeyRelease) { + LibX11.XKeyEvent keyEvent = event.getKeyEvent(); + long keysym = LibX11.INSTANCE.XLookupKeysym(keyEvent, 0); + String keyString = LibX11.INSTANCE.XKeysymToString(keysym); + System.out.println("KeyRelease: " + keyString + " (keycode: " + keyEvent.keycode + ")"); + } + } + + Thread.sleep(10); + } + + System.out.println("\nTotal events received: " + totalEvents); + + if (grabResult == LibX11.GrabSuccess) { + LibX11.INSTANCE.XUngrabKeyboard(display, LibX11.CurrentTime); + System.out.println("Keyboard ungrabbed"); + } + + LibX11.INSTANCE.XCloseDisplay(display); + System.out.println("Display closed"); + } +} diff --git a/src/main/java/mousemaster/platform/linux/X11Test2.java b/src/main/java/mousemaster/platform/linux/X11Test2.java new file mode 100644 index 00000000..881a3e9b --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/X11Test2.java @@ -0,0 +1,105 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Pointer; + +/** + * Test X11 keyboard events with sync and better debugging + */ +public class X11Test2 { + public static void main(String[] args) throws InterruptedException { + System.out.println("X11 Keyboard Test v2"); + System.out.println("After keyboard is grabbed, press some keys..."); + System.out.println("Press Escape to exit early\n"); + + Pointer display = LibX11.INSTANCE.XOpenDisplay(null); + if (display == null) { + System.err.println("Failed to open X11 display"); + return; + } + System.out.println("Display opened"); + + long rootWindow = LibX11.INSTANCE.XDefaultRootWindow(display); + System.out.println("Root window: " + rootWindow); + + LibX11.INSTANCE.XSync(display, false); + + Thread.sleep(1000); + + System.out.println("\nGrabbing keyboard..."); + int grabResult = LibX11.INSTANCE.XGrabKeyboard( + display, + rootWindow, + 0, + LibX11.GrabModeAsync, + LibX11.GrabModeAsync, + LibX11.CurrentTime + ); + + if (grabResult != LibX11.GrabSuccess) { + System.err.println("Failed to grab keyboard: " + grabResult); + LibX11.INSTANCE.XCloseDisplay(display); + return; + } + + System.out.println("Keyboard grabbed! Press keys now...\n"); + + LibX11.INSTANCE.XSync(display, false); + + boolean running = true; + int totalEvents = 0; + long startTime = System.currentTimeMillis(); + + while (running && (System.currentTimeMillis() - startTime < 15000)) { + LibX11.INSTANCE.XSync(display, false); + int pending = LibX11.INSTANCE.XPending(display); + + if (pending > 0) { + System.out.println(">>> " + pending + " events pending"); + } + + while (LibX11.INSTANCE.XPending(display) > 0) { + LibX11.XEvent event = new LibX11.XEvent(); + LibX11.INSTANCE.XNextEvent(display, event); + totalEvents++; + + System.out.println("Event #" + totalEvents + " - Type: " + event.type); + + if (event.type == LibX11.KeyPress) { + LibX11.XKeyEvent keyEvent = event.getKeyEvent(); + long keysym = LibX11.INSTANCE.XLookupKeysym(keyEvent, 0); + String keyString = LibX11.INSTANCE.XKeysymToString(keysym); + + System.out.println(" KeyPress: " + keyString + + " (keycode=" + keyEvent.keycode + + ", state=" + keyEvent.state + + ", window=" + keyEvent.window + ")"); + + if ("Escape".equals(keyString)) { + System.out.println(" Escape pressed - exiting"); + running = false; + } + + } else if (event.type == LibX11.KeyRelease) { + LibX11.XKeyEvent keyEvent = event.getKeyEvent(); + long keysym = LibX11.INSTANCE.XLookupKeysym(keyEvent, 0); + String keyString = LibX11.INSTANCE.XKeysymToString(keysym); + + System.out.println(" KeyRelease: " + keyString + + " (keycode=" + keyEvent.keycode + ")"); + } + } + + Thread.sleep(10); + } + + System.out.println("\n========================="); + System.out.println("Total events received: " + totalEvents); + + LibX11.INSTANCE.XUngrabKeyboard(display, LibX11.CurrentTime); + LibX11.INSTANCE.XSync(display, false); + System.out.println("Keyboard ungrabbed"); + + LibX11.INSTANCE.XCloseDisplay(display); + System.out.println("Display closed"); + } +} diff --git a/src/main/java/mousemaster/qt/GridWindow.java b/src/main/java/mousemaster/qt/GridWindow.java new file mode 100644 index 00000000..b8e302a5 --- /dev/null +++ b/src/main/java/mousemaster/qt/GridWindow.java @@ -0,0 +1,94 @@ +package mousemaster.qt; + +import io.qt.core.Qt; +import io.qt.gui.QColor; +import io.qt.gui.QPaintEvent; +import io.qt.gui.QPainter; +import io.qt.gui.QPen; +import mousemaster.Grid; + +/** + * Platform-agnostic Qt window for displaying the grid overlay. + * Renders grid lines based on Grid configuration. + * + * NOTE: This is a shared, platform-agnostic implementation currently used by Linux. + * Windows has its own implementation using Win32 API (WindowsOverlay.createGridWindow). + * TODO: Consider refactoring Windows to use this shared Qt-based implementation + * instead of platform-specific Win32 rendering, which would reduce code duplication + * and improve maintainability. + */ +public class GridWindow extends TransparentWindow { + + private Grid grid; + + public GridWindow() { + super(); + } + + public void setGrid(Grid grid) { + this.grid = grid; + + // Position and size the window + setGeometry(grid.x(), grid.y(), grid.width(), grid.height()); + + // Make window visible + show(); + + // Request repaint + update(); + } + + public void clearGrid() { + this.grid = null; + hide(); + } + + @Override + protected void paintEvent(QPaintEvent event) { + super.paintEvent(event); + + if (grid == null || !grid.lineVisible()) { + return; + } + + QPainter painter = new QPainter(this); + + // Parse hex color (format: "#RRGGBB" or "RRGGBB") + String hexColor = grid.lineHexColor(); + if (hexColor.startsWith("#")) { + hexColor = hexColor.substring(1); + } + + int rgb = Integer.parseInt(hexColor, 16); + int r = (rgb >> 16) & 0xFF; + int g = (rgb >> 8) & 0xFF; + int b = rgb & 0xFF; + + // Set up pen for drawing lines + QPen pen = new QPen(new QColor(r, g, b)); + pen.setWidth((int) Math.round(grid.lineThickness())); + pen.setStyle(Qt.PenStyle.SolidLine); + painter.setPen(pen); + + int rowCount = grid.rowCount(); + int columnCount = grid.columnCount(); + int cellWidth = grid.width() / columnCount; + int cellHeight = grid.height() / rowCount; + + // Draw vertical lines + for (int col = 0; col <= columnCount; col++) { + int x = (col == columnCount) ? grid.width() - 1 : col * cellWidth; + painter.drawLine(x, 0, x, grid.height()); + } + + // Draw horizontal lines + for (int row = 0; row <= rowCount; row++) { + int y = (row == rowCount) ? grid.height() - 1 : row * cellHeight; + painter.drawLine(0, y, grid.width(), y); + } + + painter.end(); + painter.dispose(); + pen.dispose(); + } +} diff --git a/src/main/java/mousemaster/qt/HintMeshWindow.java b/src/main/java/mousemaster/qt/HintMeshWindow.java new file mode 100644 index 00000000..234fc836 --- /dev/null +++ b/src/main/java/mousemaster/qt/HintMeshWindow.java @@ -0,0 +1,116 @@ +package mousemaster.qt; + +import io.qt.core.Qt; +import io.qt.gui.*; +import mousemaster.Hint; +import mousemaster.HintMesh; +import mousemaster.Key; + +import java.util.List; + +/** + * Platform-agnostic Qt window for displaying hint mesh overlay. + * Renders letter labels at hint positions. + * + * NOTE: This is a shared, platform-agnostic implementation currently used by Linux. + * Windows has its own more sophisticated implementation using QLabel widgets, pixmap caching, + * and animations (see WindowsOverlay.HintMeshWindow record and ClearBackgroundQLabel class). + * TODO: Consider whether to: + * 1. Enhance this shared version with Windows' advanced features (animations, caching) + * and migrate Windows to use it, OR + * 2. Keep this as a simple version for basic platforms and maintain Windows' advanced version + * The former would reduce code duplication but requires careful refactoring of Windows' complex + * hint rendering pipeline. + */ +public class HintMeshWindow extends TransparentWindow { + + private HintMesh hintMesh; + + public HintMeshWindow() { + super(); + } + + public void setHintMesh(HintMesh hintMesh) { + this.hintMesh = hintMesh; + + // Make window fullscreen to cover all hints + setGeometry(0, 0, 1920, 1080); + + // Make window visible + show(); + + // Request repaint + update(); + } + + public void clearHints() { + this.hintMesh = null; + hide(); + } + + @Override + protected void paintEvent(QPaintEvent event) { + super.paintEvent(event); + + if (hintMesh == null || !hintMesh.visible()) { + return; + } + + QPainter painter = new QPainter(this); + + // Set up font for hint labels + QFont font = new QFont("Arial", 24); + font.setBold(true); + painter.setFont(font); + + // Draw each hint + for (Hint hint : hintMesh.hints()) { + // Get the label text from the key sequence + String label = getHintLabel(hint.keySequence()); + + // Calculate position (hint has centerX/centerY, we need top-left for text) + int x = (int) (hint.centerX() - hint.cellWidth() / 2); + int y = (int) (hint.centerY() - hint.cellHeight() / 2); + int width = (int) hint.cellWidth(); + int height = (int) hint.cellHeight(); + + // Draw background box + QPen boxPen = new QPen(new QColor(255, 255, 255, 200)); + boxPen.setWidth(2); + painter.setPen(boxPen); + painter.setBrush(new QBrush(new QColor(0, 0, 0, 150))); + painter.drawRoundedRect(x, y, width, height, 5, 5); + + // Draw label text + QPen textPen = new QPen(new QColor(255, 255, 255)); + painter.setPen(textPen); + painter.drawText(x, y, width, height, + Qt.AlignmentFlag.AlignCenter.value(), label); + + boxPen.dispose(); + textPen.dispose(); + } + + painter.end(); + painter.dispose(); + font.dispose(); + } + + private String getHintLabel(List keySequence) { + if (keySequence.isEmpty()) { + return ""; + } + + StringBuilder label = new StringBuilder(); + for (Key key : keySequence) { + if (key.character() != null) { + label.append(key.character()); + } else if (key.staticSingleCharacterName() != null) { + label.append(key.staticSingleCharacterName()); + } else if (key.staticName() != null) { + label.append(key.staticName()); + } + } + return label.toString(); + } +} From 6eca16603438e3a45acd4d5747e982578c7c9857 Mon Sep 17 00:00:00 2001 From: SudoWatson Date: Wed, 8 Jul 2026 21:03:53 -0400 Subject: [PATCH 02/23] Simplify flake: drop pinned nixpkgs-qt682, no system Qt needed Qt is bundled inside the JAR via qtjambi-native-linux-x64 (same as Windows bundles DLLs via qtjambi-native-windows-x64). The dev shell only needs JDK21/Maven for building, and the system libs that the bundled Qt .so files will dlopen at runtime (xcb, xkbcommon, GL, etc). Co-Authored-By: Claude Sonnet 4.6 --- flake.lock | 25 ++++------------------- flake.nix | 60 +++++++++++++++++++++++++++++++----------------------- 2 files changed, 39 insertions(+), 46 deletions(-) diff --git a/flake.lock b/flake.lock index dde0f6fe..23c8470c 100644 --- a/flake.lock +++ b/flake.lock @@ -20,11 +20,11 @@ }, "nixpkgs": { "locked": { - "lastModified": 1782535326, - "narHash": "sha256-ZeRxu4yn6shd3SNF5ZUQb4r7BaVo1zBKMjRhfoNSBmw=", + "lastModified": 1783389287, + "narHash": "sha256-0xIy4dVLqq47rA+mRy0hXDfjhQd4E5PoIns/RmB7nR4=", "owner": "nixos", "repo": "nixpkgs", - "rev": "714a5f8c4ead6b31148d829288440ed033ccc041", + "rev": "0ad6f47ea4fe188f4bc8f0380f93ae8523337c6c", "type": "github" }, "original": { @@ -34,27 +34,10 @@ "type": "github" } }, - "nixpkgs-qt682": { - "locked": { - "lastModified": 1738334133, - "narHash": "sha256-hR/KuYpJgLjuyeQIs7xiTNAjNd66OXiq/6L+D9nWYV8=", - "owner": "nixos", - "repo": "nixpkgs", - "rev": "86c0981230fd186dcefe317db93963c0bcdd1810", - "type": "github" - }, - "original": { - "owner": "nixos", - "repo": "nixpkgs", - "rev": "86c0981230fd186dcefe317db93963c0bcdd1810", - "type": "github" - } - }, "root": { "inputs": { "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs", - "nixpkgs-qt682": "nixpkgs-qt682" + "nixpkgs": "nixpkgs" } }, "systems": { diff --git a/flake.nix b/flake.nix index 34a3a125..cf3bb8ad 100644 --- a/flake.nix +++ b/flake.nix @@ -3,15 +3,13 @@ inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05"; - nixpkgs-qt682.url = "github:nixos/nixpkgs/86c0981230fd186dcefe317db93963c0bcdd1810"; flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, nixpkgs-qt682, flake-utils }: + outputs = { self, nixpkgs, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; - pkgs-qt682 = import nixpkgs-qt682 { inherit system; }; in { devShells.default = pkgs.mkShell { packages = with pkgs; [ @@ -19,40 +17,52 @@ jdk21 maven - # X11 libraries for JNA bindings + # X11 libs loaded by JNA at runtime xorg.libX11 xorg.libXrandr - xorg.libXtst - xorg.libxcb - - # Qt 6.8.2 libraries - pinned to nixpkgs commit before 6.8.3 to match QtJambi 6.8.2 - pkgs-qt682.qt6.full - pkgs-qt682.qt6.qtbase - - # Additional X11 dependencies + xorg.libXtst # XTest extension - for key/mouse injection xorg.libXi xorg.libXext xorg.libXrender xorg.libXfixes - # Build tools - pkg-config + # System libs that the bundled Qt 6.8.2 .so files will dlopen + xorg.libxcb + xcb-util + xcb-util-image + xcb-util-keysyms + xcb-util-renderutil + xcb-util-wm + libxkbcommon + fontconfig + freetype + mesa # provides libGL / libEGL ]; shellHook = '' export JAVA_HOME="${pkgs.jdk21}" - export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath [ - pkgs.xorg.libX11 - pkgs.xorg.libXrandr - pkgs.xorg.libXtst - pkgs.xorg.libxcb - pkgs-qt682.qt6.qtbase - pkgs.xorg.libXi - pkgs.xorg.libXext - ]}:$LD_LIBRARY_PATH" - echo "Mousemaster development environment loaded" + export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath (with pkgs; [ + xorg.libX11 + xorg.libXrandr + xorg.libXtst + xorg.libXi + xorg.libXext + xorg.libXrender + xorg.libXfixes + xorg.libxcb + xcb-util + xcb-util-image + xcb-util-keysyms + xcb-util-renderutil + xcb-util-wm + libxkbcommon + fontconfig + freetype + mesa + ])}:$LD_LIBRARY_PATH" + echo "Mousemaster dev environment ready" echo "Java: $(java -version 2>&1 | head -n1)" - echo "Maven: $(mvn -version | head -n1)" + echo "Maven: $(mvn -version 2>&1 | head -n1)" ''; }; }); From 013bf56cd8c62a2ecf49b4d5cad11bb2af9f0b53 Mon Sep 17 00:00:00 2001 From: SudoWatson Date: Wed, 8 Jul 2026 21:13:53 -0400 Subject: [PATCH 03/23] Update flake --- flake.nix | 52 ++++++++++++++++++++++++++-------------------------- 1 file changed, 26 insertions(+), 26 deletions(-) diff --git a/flake.nix b/flake.nix index cf3bb8ad..de2ac4e2 100644 --- a/flake.nix +++ b/flake.nix @@ -18,21 +18,21 @@ maven # X11 libs loaded by JNA at runtime - xorg.libX11 - xorg.libXrandr - xorg.libXtst # XTest extension - for key/mouse injection - xorg.libXi - xorg.libXext - xorg.libXrender - xorg.libXfixes + libx11 + libxrandr + libxtst # XTest extension - for key/mouse injection + libxi + libxext + libxrender + libxfixes # System libs that the bundled Qt 6.8.2 .so files will dlopen - xorg.libxcb - xcb-util - xcb-util-image - xcb-util-keysyms - xcb-util-renderutil - xcb-util-wm + libxcb + xcbutil + xcbutilimage + xcbutilkeysyms + xcbutilrenderutil + xcbutilwm libxkbcommon fontconfig freetype @@ -42,19 +42,19 @@ shellHook = '' export JAVA_HOME="${pkgs.jdk21}" export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath (with pkgs; [ - xorg.libX11 - xorg.libXrandr - xorg.libXtst - xorg.libXi - xorg.libXext - xorg.libXrender - xorg.libXfixes - xorg.libxcb - xcb-util - xcb-util-image - xcb-util-keysyms - xcb-util-renderutil - xcb-util-wm + libx11 + libxrandr + libxtst + libxi + libxext + libxrender + libxfixes + libxcb + xcbutil + xcbutilimage + xcbutilkeysyms + xcbutilrenderutil + xcbutilwm libxkbcommon fontconfig freetype From 04e29f5a5bf8f9f9c713908f1428aaebc691b7c4 Mon Sep 17 00:00:00 2001 From: SudoWatson Date: Thu, 9 Jul 2026 05:25:04 -0400 Subject: [PATCH 04/23] Fix QT versioning --- flake.lock | 19 +++- flake.nix | 14 ++- pom.xml | 38 +++++-- .../mousemaster/MousemasterApplication.java | 7 +- src/main/java/mousemaster/QtManager.java | 98 +++++++++++++++---- 5 files changed, 144 insertions(+), 32 deletions(-) diff --git a/flake.lock b/flake.lock index 23c8470c..4573cb3a 100644 --- a/flake.lock +++ b/flake.lock @@ -34,10 +34,27 @@ "type": "github" } }, + "nixpkgs-qt68": { + "locked": { + "lastModified": 1751274312, + "narHash": "sha256-/bVBlRpECLVzjV19t5KMdMFWSwKLtb5RyXdjz3LJT+g=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "50ab793786d9de88ee30ec4e4c24fb4236fc2674", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-24.11", + "repo": "nixpkgs", + "type": "github" + } + }, "root": { "inputs": { "flake-utils": "flake-utils", - "nixpkgs": "nixpkgs" + "nixpkgs": "nixpkgs", + "nixpkgs-qt68": "nixpkgs-qt68" } }, "systems": { diff --git a/flake.nix b/flake.nix index de2ac4e2..1b218770 100644 --- a/flake.nix +++ b/flake.nix @@ -3,13 +3,17 @@ inputs = { nixpkgs.url = "github:nixos/nixpkgs/nixos-26.05"; + # nixos-24.11 ships Qt 6.8.x, matching QtJambi 6.8.2. + # QtJambi does a hard version check and refuses Qt 6.9+. + nixpkgs-qt68.url = "github:nixos/nixpkgs/nixos-24.11"; flake-utils.url = "github:numtide/flake-utils"; }; - outputs = { self, nixpkgs, flake-utils }: + outputs = { self, nixpkgs, nixpkgs-qt68, flake-utils }: flake-utils.lib.eachDefaultSystem (system: let pkgs = import nixpkgs { inherit system; }; + pkgs68 = import nixpkgs-qt68 { inherit system; }; in { devShells.default = pkgs.mkShell { packages = with pkgs; [ @@ -26,7 +30,10 @@ libxrender libxfixes - # System libs that the bundled Qt 6.8.2 .so files will dlopen + # Qt 6.8.x runtime — must match QtJambi 6.8.2 (from nixos-24.11) + pkgs68.qt6.qtbase + + # System libs that Qt 6 and the bundled QtJambi .so files will dlopen libxcb xcbutil xcbutilimage @@ -41,7 +48,7 @@ shellHook = '' export JAVA_HOME="${pkgs.jdk21}" - export LD_LIBRARY_PATH="${pkgs.lib.makeLibraryPath (with pkgs; [ + export LD_LIBRARY_PATH="${pkgs68.qt6.qtbase}/lib:${pkgs.lib.makeLibraryPath (with pkgs; [ libx11 libxrandr libxtst @@ -60,6 +67,7 @@ freetype mesa ])}:$LD_LIBRARY_PATH" + export QT_QPA_PLATFORM_PLUGIN_PATH="${pkgs68.qt6.qtbase}/lib/qt-6/plugins/platforms" echo "Mousemaster dev environment ready" echo "Java: $(java -version 2>&1 | head -n1)" echo "Maven: $(mvn -version 2>&1 | head -n1)" diff --git a/pom.xml b/pom.xml index ba1864dd..a5e7475b 100644 --- a/pom.xml +++ b/pom.xml @@ -55,11 +55,6 @@ qtjambi 6.8.2 - - io.qtjambi - qtjambi-native-windows-x64 - 6.8.2 - com.google.code.gson gson @@ -80,6 +75,35 @@ + + windows + + windows + + + + io.qtjambi + qtjambi-native-windows-x64 + 6.8.2 + + + + + linux + + unix + + + mousemaster.platform.linux.LinuxMain + + + + io.qtjambi + qtjambi-native-linux-x64 + 6.8.2 + + + native @@ -161,7 +185,9 @@ src/main/resources true - **/*.dll + **/*.dll + **/*.so + **/*.so.* diff --git a/src/main/java/mousemaster/MousemasterApplication.java b/src/main/java/mousemaster/MousemasterApplication.java index 7c6cfeaa..562a6981 100644 --- a/src/main/java/mousemaster/MousemasterApplication.java +++ b/src/main/java/mousemaster/MousemasterApplication.java @@ -10,6 +10,7 @@ import org.slf4j.LoggerFactory; import org.slf4j.bridge.SLF4JBridgeHandler; +import java.io.File; import java.util.Scanner; import java.util.logging.LogManager; @@ -42,9 +43,11 @@ public static void setTempDirectory(String tempDirectory) { } if (MousemasterApplication.tempDirectory == null) MousemasterApplication.tempDirectory = - System.getProperty("java.io.tmpdir") + "mousemaster-" + + System.getProperty("java.io.tmpdir") + File.separator + "mousemaster-" + System.getProperty("user.name").hashCode(); - System.setProperty("jna.tmpdir", MousemasterApplication.tempDirectory + "/jna"); + String jnaTmpDir = MousemasterApplication.tempDirectory + "/jna"; + System.setProperty("jna.tmpdir", jnaTmpDir); + new File(jnaTmpDir).mkdirs(); } public static void shutdownAfterException(Throwable e, Platform platform, diff --git a/src/main/java/mousemaster/QtManager.java b/src/main/java/mousemaster/QtManager.java index 8a3fe6ee..aff19e8f 100644 --- a/src/main/java/mousemaster/QtManager.java +++ b/src/main/java/mousemaster/QtManager.java @@ -9,16 +9,18 @@ import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; -import java.nio.file.Files; -import java.nio.file.Path; -import java.nio.file.Paths; -import java.nio.file.StandardOpenOption; +import java.nio.file.*; import java.util.List; +import java.util.Map; public class QtManager { private static final Logger logger = LoggerFactory.getLogger(QtManager.class.getName()); + private static final boolean IS_LINUX = + System.getProperty("os.name").toLowerCase().contains("linux"); + + // Windows: Qt6 runtime DLLs bundled in src/main/resources/qt/bin/ private static final List windowsResourcesPaths = List.of( "qt/bin/Qt6Core.dll", "qt/bin/Qt6Gui.dll", @@ -33,7 +35,7 @@ public class QtManager { "qt/plugins/platforms/qwindowsd.dll" ); - private static final List qtJambiPaths = List.of( + private static final List qtJambiWindowsPaths = List.of( "bin/QtJambi6.dll", "bin/QtJambiCore6.dll", "bin/QtJambiGui6.dll", @@ -46,15 +48,40 @@ public class QtManager { "qt-msvcp/msvcp140_2.dll" ); + // Linux: QtJambi bridge .so files from qtjambi-native-linux-x64-6.8.2.jar + // Qt6 runtime (libQt6Core.so.6 etc.) comes from LD_LIBRARY_PATH (nix devshell / system) + private static final List qtJambiLinuxPaths = List.of( + "lib/libQtJambi.so.6.8.2", + "lib/libQtJambiCore.so.6.8.2", + "lib/libQtJambiGui.so.6.8.2", + "lib/libQtJambiWidgets.so.6.8.2", + "lib/libQtJambiGuiRhi.so.6.8.2" + ); + + // For each base name, the symlinks the dynamic linker needs (per qtjambi-deployment.xml) + private static final Map linuxSymlinkBases = Map.of( + "libQtJambi", "libQtJambi.so.6.8.2", + "libQtJambiCore", "libQtJambiCore.so.6.8.2", + "libQtJambiGui", "libQtJambiGui.so.6.8.2", + "libQtJambiWidgets", "libQtJambiWidgets.so.6.8.2", + "libQtJambiGuiRhi", "libQtJambiGuiRhi.so.6.8.2" + ); + public static void initialize() throws IOException { - File extractDirectory = createExtractDirectory( - MousemasterApplication.tempDirectory); + if (IS_LINUX) + initializeLinux(); + else + initializeWindows(); + } + + private static void initializeWindows() throws IOException { + File extractDirectory = createExtractDirectory(MousemasterApplication.tempDirectory); for (String resourcesPath : windowsResourcesPaths) { Path extractPath = Paths.get(extractDirectory.getAbsolutePath() + "/" + resourcesPath); Files.createDirectories(extractPath.getParent()); extractResourceFile(resourcesPath, extractPath); } - for (String qtJambiPath : qtJambiPaths) { + for (String qtJambiPath : qtJambiWindowsPaths) { Path extractPath = Paths.get(extractDirectory.getAbsolutePath() + "/qt/" + qtJambiPath); extractResourceFile(qtJambiPath, extractPath); } @@ -62,14 +89,7 @@ public static void initialize() throws IOException { System.setProperty("io.qt.library-path-override", extractDirectory.getAbsolutePath() + "/qt/bin"); // QtJambi expects DLLs in io.qt.library-path-override, and io.qt.library-path-override/../plugins/platforms - -// System.setProperty("QT_ENABLE_HIGHDPI_SCALING", "0"); -// setEnv("QT_ENABLE_HIGHDPI_SCALING", "0"); -// System.setProperty("QT_AUTO_SCREEN_SCALE_FACTOR", "0"); -// System.setProperty("QT_SCALE_FACTOR", "1"); - // https://forum.qt.io/topic/141511/qt_enable_highdpi_scaling-has-no-effect try { - // Just to trigger the static initializer which loads DLLs. QtUtilities.jambiDeploymentDir(); } catch (UnsatisfiedLinkError e) { for (String msvcpDllResourcePath : msvcpDllPaths) { @@ -81,11 +101,50 @@ public static void initialize() throws IOException { e2.setStackTrace(e.getStackTrace()); throw e2; } - QtUtilities.putenv("QT_ENABLE_HIGHDPI_SCALING", "0"); // Only works on Windows? + QtUtilities.putenv("QT_ENABLE_HIGHDPI_SCALING", "0"); logger.trace("highDpiScaleFactorRoundingPolicy is " + QApplication.highDpiScaleFactorRoundingPolicy()); - // Default font engine on Windows is directwrite. Antialiasing seems better with gdi. QApplication.initialize(new String[] { "-platform", "windows:fontengine=gdi" }); -// QApplication.initialize(new String[] { }); + } + + private static void initializeLinux() throws IOException { + File extractDirectory = createExtractDirectory(MousemasterApplication.tempDirectory); + Path libDir = Paths.get(extractDirectory.getAbsolutePath(), "qt", "lib"); + Files.createDirectories(libDir); + + // Extract QtJambi bridge .so files from the qtjambi-native-linux-x64 JAR on classpath + for (String qtJambiPath : qtJambiLinuxPaths) { + String fileName = qtJambiPath.substring(qtJambiPath.lastIndexOf('/') + 1); + Path extractPath = libDir.resolve(fileName); + extractResourceFile(qtJambiPath, extractPath); + } + + // Create SONAME symlinks required by the dynamic linker (from qtjambi-deployment.xml) + for (Map.Entry entry : linuxSymlinkBases.entrySet()) { + String base = entry.getKey(); + String target = entry.getValue(); + createSymlinkIfAbsent(libDir.resolve(base + ".so"), libDir.resolve(target)); + createSymlinkIfAbsent(libDir.resolve(base + ".so.6"), libDir.resolve(target)); + createSymlinkIfAbsent(libDir.resolve(base + ".so.6.8"), libDir.resolve(target)); + } + + logger.trace("Extracted QtJambi .so files to " + libDir); + System.setProperty("io.qt.library-path-override", libDir.toString()); + // Qt6 runtime (libQt6Core.so.6 etc.) resolved via LD_LIBRARY_PATH from nix devshell / system + QtUtilities.jambiDeploymentDir(); + logger.trace("highDpiScaleFactorRoundingPolicy is " + QApplication.highDpiScaleFactorRoundingPolicy()); + QApplication.initialize(new String[]{}); + } + + private static void createSymlinkIfAbsent(Path link, Path target) { + try { + // Use relative target so the symlinks work regardless of temp dir path + Path relativeTarget = link.getParent().relativize(target); + Files.createSymbolicLink(link, relativeTarget); + } catch (FileAlreadyExistsException ignored) { + // Already exists from a prior run — fine + } catch (IOException e) { + logger.warn("Could not create symlink {} -> {}: {}", link, target, e.getMessage()); + } } private static void extractResourceFile(String resourcesPath, Path extractPath) @@ -97,8 +156,7 @@ private static void extractResourceFile(String resourcesPath, Path extractPath) StandardOpenOption.WRITE)) { inputStream.transferTo(outputStream); } catch (IOException e) { - // java.nio.file.FileSystemException: C:\Users\x\AppData\Local\Temp\mousemaster-110364797\qt\bin\Qt6Core.dll: The process cannot access the file because it is being used by another process - // logger.debug("Unable to extract resource file " + resourcesPath, e); + // File in use (Windows: DLL loaded by a prior process instance) — skip silently } } } From 0a9916514a8a8bc16ed0de2b7a2d0ddf2e5c2feb Mon Sep 17 00:00:00 2001 From: SudoWatson Date: Thu, 9 Jul 2026 05:50:45 -0400 Subject: [PATCH 05/23] Fix fullscreen --- src/main/java/mousemaster/qt/HintMeshWindow.java | 8 +------- .../java/mousemaster/qt/TransparentWindow.java | 15 +++++++++++++-- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/src/main/java/mousemaster/qt/HintMeshWindow.java b/src/main/java/mousemaster/qt/HintMeshWindow.java index 234fc836..c65b890c 100644 --- a/src/main/java/mousemaster/qt/HintMeshWindow.java +++ b/src/main/java/mousemaster/qt/HintMeshWindow.java @@ -32,14 +32,8 @@ public HintMeshWindow() { public void setHintMesh(HintMesh hintMesh) { this.hintMesh = hintMesh; - - // Make window fullscreen to cover all hints - setGeometry(0, 0, 1920, 1080); - - // Make window visible + setGeometry(primaryScreenGeometry()); show(); - - // Request repaint update(); } diff --git a/src/main/java/mousemaster/qt/TransparentWindow.java b/src/main/java/mousemaster/qt/TransparentWindow.java index 4a1d7b38..69014464 100644 --- a/src/main/java/mousemaster/qt/TransparentWindow.java +++ b/src/main/java/mousemaster/qt/TransparentWindow.java @@ -6,6 +6,7 @@ import io.qt.gui.QColor; import io.qt.gui.QPaintEvent; import io.qt.gui.QPainter; +import io.qt.widgets.QApplication; import io.qt.widgets.QWidget; public class TransparentWindow extends QWidget { @@ -14,11 +15,21 @@ public class TransparentWindow extends QWidget { private QRect backgroundRect; public TransparentWindow() { - // WindowDoesNotAcceptFocus is not implemented for Windows. - setWindowFlags(Qt.WindowType.FramelessWindowHint); + // FramelessWindowHint: no title bar or border. + // X11BypassWindowManagerHint: bypass tiling WMs (e.g. Hyprland via XWayland) so the + // window floats at the exact geometry we specify rather than being tiled. + // WindowStaysOnTopHint: appear above all other windows. + setWindowFlags(Qt.WindowType.FramelessWindowHint, + Qt.WindowType.X11BypassWindowManagerHint, + Qt.WindowType.WindowStaysOnTopHint); setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground); } + protected static QRect primaryScreenGeometry() { + var screen = QApplication.primaryScreen(); + return screen != null ? screen.geometry() : new QRect(0, 0, 1920, 1080); + } + public void setBackground(QColor color, QRect rect) { if (this.backgroundRect != null) this.backgroundRect.dispose(); From cab4d2721e2dbbf8fc379144aa33d95c671824d3 Mon Sep 17 00:00:00 2001 From: SudoWatson Date: Sat, 11 Jul 2026 16:10:51 -0400 Subject: [PATCH 06/23] Mouse moving --- .../mousemaster/platform/linux/LibUinput.java | 149 ++++++++++++++++++ .../mousemaster/platform/linux/LibX11.java | 20 +++ .../mousemaster/platform/linux/LibXTest.java | 13 ++ .../platform/linux/LinuxMouse.java | 105 ++++++++---- .../platform/linux/LinuxOverlay.java | 5 +- .../platform/linux/LinuxPlatform.java | 11 +- 6 files changed, 271 insertions(+), 32 deletions(-) create mode 100644 src/main/java/mousemaster/platform/linux/LibUinput.java create mode 100644 src/main/java/mousemaster/platform/linux/LibXTest.java diff --git a/src/main/java/mousemaster/platform/linux/LibUinput.java b/src/main/java/mousemaster/platform/linux/LibUinput.java new file mode 100644 index 00000000..a5162eb1 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LibUinput.java @@ -0,0 +1,149 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Library; +import com.sun.jna.Memory; +import com.sun.jna.Native; +import com.sun.jna.NativeLong; +import com.sun.jna.Pointer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.nio.charset.StandardCharsets; + +/** + * Helper for Linux uinput virtual input device. + * Provides mouse button clicks and scroll wheel injection that works on + * both X11 and native Wayland (unlike XTest which only reaches X11 clients). + */ +public class LibUinput { + + private static final Logger logger = LoggerFactory.getLogger(LibUinput.class); + + // ioctl numbers for uinput on x86_64 Linux + // Computed via _IOW('U', nr, int) and _IO('U', nr) + static final long UI_SET_EVBIT = 0x40045564L; // _IOW('U', 100, int) + static final long UI_SET_KEYBIT = 0x40045565L; // _IOW('U', 101, int) + static final long UI_SET_RELBIT = 0x40045567L; // _IOW('U', 103, int) + static final long UI_DEV_CREATE = 0x5501L; // _IO('U', 1) + static final long UI_DEV_DESTROY = 0x5502L; // _IO('U', 2) + + // Event types (linux/input-event-codes.h) + static final int EV_SYN = 0; + static final int EV_KEY = 1; + static final int EV_REL = 2; + + static final int SYN_REPORT = 0; + + // Mouse button codes + static final int BTN_LEFT = 0x110; + static final int BTN_RIGHT = 0x111; + static final int BTN_MIDDLE = 0x112; + + // Relative axis codes + static final int REL_X = 0; + static final int REL_Y = 1; + static final int REL_HWHEEL = 6; + static final int REL_WHEEL = 8; + + static final short BUS_VIRTUAL = 0x06; + + // open() flags on Linux x86_64 + static final int O_WRONLY = 1; + static final int O_NONBLOCK = 0x800; + + // sizeof(uinput_user_dev): 80 (name) + 8 (input_id) + 4 (ff_effects_max) + 4*256 (abs arrays) = 1116 + private static final int UINPUT_USER_DEV_SIZE = 1116; + + // sizeof(input_event) on x86_64: 16 (timeval) + 2 (type) + 2 (code) + 4 (value) = 24 + static final int INPUT_EVENT_SIZE = 24; + + static final String UINPUT_PATH = "/dev/uinput"; + // Distinct name lets the evdev keyboard read-loop filter out this virtual device + static final String DEVICE_NAME = "mousemaster-mouse"; + + interface CLib extends Library { + CLib INSTANCE = Native.load("c", CLib.class); + + int open(String path, int flags); + int close(int fd); + NativeLong write(int fd, Pointer buf, NativeLong count); + int ioctl(int fd, NativeLong request, int arg); + } + + static int createMouseDevice() { + int fd = CLib.INSTANCE.open(UINPUT_PATH, O_WRONLY | O_NONBLOCK); + if (fd < 0) { + logger.error("Cannot open {} (errno likely EACCES or ENOENT).\n" + + "Add yourself to the 'uinput' group:\n" + + " sudo usermod -aG uinput $USER (then log out and back in)", + UINPUT_PATH); + return -1; + } + + requireIoctl(fd, UI_SET_EVBIT, EV_KEY, "UI_SET_EVBIT(EV_KEY)"); + requireIoctl(fd, UI_SET_EVBIT, EV_SYN, "UI_SET_EVBIT(EV_SYN)"); + requireIoctl(fd, UI_SET_EVBIT, EV_REL, "UI_SET_EVBIT(EV_REL)"); + requireIoctl(fd, UI_SET_KEYBIT, BTN_LEFT, "UI_SET_KEYBIT(BTN_LEFT)"); + requireIoctl(fd, UI_SET_KEYBIT, BTN_RIGHT, "UI_SET_KEYBIT(BTN_RIGHT)"); + requireIoctl(fd, UI_SET_KEYBIT, BTN_MIDDLE,"UI_SET_KEYBIT(BTN_MIDDLE)"); + requireIoctl(fd, UI_SET_RELBIT, REL_X, "UI_SET_RELBIT(REL_X)"); + requireIoctl(fd, UI_SET_RELBIT, REL_Y, "UI_SET_RELBIT(REL_Y)"); + requireIoctl(fd, UI_SET_RELBIT, REL_WHEEL, "UI_SET_RELBIT(REL_WHEEL)"); + requireIoctl(fd, UI_SET_RELBIT, REL_HWHEEL,"UI_SET_RELBIT(REL_HWHEEL)"); + + // Write uinput_user_dev to configure device name and bus type + Memory userDev = new Memory(UINPUT_USER_DEV_SIZE); + userDev.clear(); + byte[] nameBytes = DEVICE_NAME.getBytes(StandardCharsets.US_ASCII); + for (int i = 0; i < nameBytes.length && i < 79; i++) { + userDev.setByte(i, nameBytes[i]); + } + userDev.setShort(80, BUS_VIRTUAL); // input_id.bustype at offset 80 + NativeLong written = CLib.INSTANCE.write(fd, userDev, new NativeLong(UINPUT_USER_DEV_SIZE)); + if (written.longValue() != UINPUT_USER_DEV_SIZE) { + logger.error("uinput_user_dev write failed: wrote {} of {} bytes (errno={})", + written.longValue(), UINPUT_USER_DEV_SIZE, Native.getLastError()); + CLib.INSTANCE.close(fd); + return -1; + } + logger.debug("uinput_user_dev write ok ({} bytes)", written.longValue()); + + int result = CLib.INSTANCE.ioctl(fd, new NativeLong(UI_DEV_CREATE), 0); + if (result < 0) { + logger.error("UI_DEV_CREATE ioctl failed (result={})", result); + CLib.INSTANCE.close(fd); + return -1; + } + + logger.info("uinput mouse device '{}' created (fd={})", DEVICE_NAME, fd); + return fd; + } + + static void destroyDevice(int fd) { + if (fd >= 0) { + CLib.INSTANCE.ioctl(fd, new NativeLong(UI_DEV_DESTROY), 0); + CLib.INSTANCE.close(fd); + logger.info("uinput mouse device destroyed"); + } + } + + private static void requireIoctl(int fd, long request, int arg, String name) { + int r = CLib.INSTANCE.ioctl(fd, new NativeLong(request), arg); + if (r < 0) { + int errno = Native.getLastError(); + logger.error("ioctl {} failed: result={} errno={}", name, r, errno); + } else { + logger.debug("ioctl {} ok", name); + } + } + + static NativeLong writeInputEvent(int fd, int type, int code, int value) { + if (fd < 0) return new NativeLong(-1); + Memory event = new Memory(INPUT_EVENT_SIZE); + event.clear(); // timeval is zeroed; kernel accepts zero timestamps for synthetic events + event.setShort(16, (short) type); + event.setShort(18, (short) code); + event.setInt(20, value); + return CLib.INSTANCE.write(fd, event, new NativeLong(INPUT_EVENT_SIZE)); + } +} diff --git a/src/main/java/mousemaster/platform/linux/LibX11.java b/src/main/java/mousemaster/platform/linux/LibX11.java index abee8c57..8c5933ca 100644 --- a/src/main/java/mousemaster/platform/linux/LibX11.java +++ b/src/main/java/mousemaster/platform/linux/LibX11.java @@ -52,6 +52,15 @@ boolean XQueryPointer(Pointer display, long window, // Window management int XGetWindowAttributes(Pointer display, long window, XWindowAttributes attributesReturn); + // Cursor management + long XCreateBitmapFromData(Pointer display, long drawable, byte[] data, int width, int height); + long XCreatePixmapCursor(Pointer display, long source, long mask, + XColor fg, XColor bg, int x, int y); + int XDefineCursor(Pointer display, long window, long cursor); + int XUndefineCursor(Pointer display, long window); + int XFreePixmap(Pointer display, long pixmap); + int XFreeCursor(Pointer display, long cursor); + // Memory management int XFree(Pointer data); @@ -98,6 +107,17 @@ int XGrabKeyboard(Pointer display, long window, int ownerEvents, long XA_ATOM = 4L; long XA_WINDOW = 33L; + /** + * X11 color structure (used for cursor creation) + * Layout: unsigned long pixel (8), unsigned short red/green/blue (2 each), char flags/pad (1 each) + */ + @Structure.FieldOrder({"pixel", "red", "green", "blue", "flags", "pad"}) + class XColor extends Structure { + public long pixel; + public short red, green, blue; + public byte flags, pad; + } + /** * X11 Event union structure */ diff --git a/src/main/java/mousemaster/platform/linux/LibXTest.java b/src/main/java/mousemaster/platform/linux/LibXTest.java new file mode 100644 index 00000000..0bad1ef2 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LibXTest.java @@ -0,0 +1,13 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Library; +import com.sun.jna.Native; +import com.sun.jna.Pointer; + +public interface LibXTest extends Library { + LibXTest INSTANCE = Native.load("Xtst", LibXTest.class); + + // Bool is_press: 1 = press, 0 = release + // delay: CurrentTime = 0 + int XTestFakeButtonEvent(Pointer display, int button, int isPress, long delay); +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxMouse.java b/src/main/java/mousemaster/platform/linux/LinuxMouse.java index 2dc1628f..2d29ec95 100644 --- a/src/main/java/mousemaster/platform/linux/LinuxMouse.java +++ b/src/main/java/mousemaster/platform/linux/LinuxMouse.java @@ -1,86 +1,137 @@ package mousemaster.platform.linux; +import com.sun.jna.Pointer; import mousemaster.platform.MouseController; import org.slf4j.Logger; import org.slf4j.LoggerFactory; -/** - * Stub implementation of MouseController for Milestone 1. - * TODO: Implement X11 mouse control via XWarpPointer and XTest for Milestone 3. - */ +// X11-only implementation. Wayland cursor movement requires zwlr_virtual_pointer_v1 +// or a working uinput REL device — deferred to a later milestone. public class LinuxMouse implements MouseController { private static final Logger logger = LoggerFactory.getLogger(LinuxMouse.class); + // X11 button numbers + private static final int BTN_LEFT = 1; + private static final int BTN_MIDDLE = 2; + private static final int BTN_RIGHT = 3; + // Vertical scroll: 4 = up, 5 = down + // Horizontal scroll: 6 = left, 7 = right + + private final Pointer display; + private final long rootWindow; + private long hiddenCursor = 0; + + public LinuxMouse(Pointer display, long rootWindow) { + this.display = display; + this.rootWindow = rootWindow; + } + + public void destroy() { + if (hiddenCursor != 0) { + LibX11.INSTANCE.XFreeCursor(display, hiddenCursor); + hiddenCursor = 0; + } + } + @Override public void beginMove() { - // TODO: Begin mouse movement batch } @Override public void endMove() { - // TODO: End mouse movement batch and flush + LibX11.INSTANCE.XFlush(display); } @Override public void moveBy(boolean xForward, double dx, boolean yForward, double dy) { - // TODO: Relative mouse movement + int ix = (int) dx * (xForward ? 1 : -1); + int iy = (int) dy * (yForward ? 1 : -1); + if (ix == 0 && iy == 0) return; + // dest_window = None (0) → coords are relative to current pointer position + LibX11.INSTANCE.XWarpPointer(display, 0, 0, 0, 0, 0, 0, ix, iy); } @Override public void synchronousMoveTo(int x, int y) { - // TODO: Absolute mouse movement via XWarpPointer - logger.debug("synchronousMoveTo({}, {}) - not yet implemented", x, y); + LibX11.INSTANCE.XWarpPointer(display, 0, rootWindow, 0, 0, 0, 0, x, y); + LibX11.INSTANCE.XFlush(display); } @Override public void pressLeft() { - // TODO: Left mouse button press via XTest + buttonEvent(BTN_LEFT, true); } @Override - public void pressMiddle() { - // TODO: Middle mouse button press via XTest + public void releaseLeft() { + buttonEvent(BTN_LEFT, false); } @Override - public void pressRight() { - // TODO: Right mouse button press via XTest + public void pressMiddle() { + buttonEvent(BTN_MIDDLE, true); } @Override - public void releaseLeft() { - // TODO: Left mouse button release via XTest + public void releaseMiddle() { + buttonEvent(BTN_MIDDLE, false); } @Override - public void releaseMiddle() { - // TODO: Middle mouse button release via XTest + public void pressRight() { + buttonEvent(BTN_RIGHT, true); } @Override public void releaseRight() { - // TODO: Right mouse button release via XTest + buttonEvent(BTN_RIGHT, false); } @Override - public void wheelHorizontallyBy(boolean forward, double delta) { - // TODO: Horizontal wheel scroll via XTest + public void wheelVerticallyBy(boolean forward, double delta) { + // forward = away from user = scroll up = button 4 + int button = forward ? 4 : 5; + int count = Math.max(1, (int) delta); + for (int i = 0; i < count; i++) { + buttonEvent(button, true); + buttonEvent(button, false); + } + LibX11.INSTANCE.XFlush(display); } @Override - public void wheelVerticallyBy(boolean forward, double delta) { - // TODO: Vertical wheel scroll via XTest + public void wheelHorizontallyBy(boolean forward, double delta) { + // forward = right = button 7 + int button = forward ? 7 : 6; + int count = Math.max(1, (int) delta); + for (int i = 0; i < count; i++) { + buttonEvent(button, true); + buttonEvent(button, false); + } + LibX11.INSTANCE.XFlush(display); } @Override - public void showCursor() { - // TODO: Show cursor (if hidden) + public void hideCursor() { + if (hiddenCursor == 0) { + byte[] blankData = {0}; + long pixmap = LibX11.INSTANCE.XCreateBitmapFromData(display, rootWindow, blankData, 1, 1); + LibX11.XColor black = new LibX11.XColor(); + hiddenCursor = LibX11.INSTANCE.XCreatePixmapCursor(display, pixmap, pixmap, black, black, 0, 0); + LibX11.INSTANCE.XFreePixmap(display, pixmap); + } + LibX11.INSTANCE.XDefineCursor(display, rootWindow, hiddenCursor); + LibX11.INSTANCE.XFlush(display); } @Override - public void hideCursor() { - // TODO: Hide cursor + public void showCursor() { + LibX11.INSTANCE.XUndefineCursor(display, rootWindow); + LibX11.INSTANCE.XFlush(display); } + private void buttonEvent(int button, boolean press) { + LibXTest.INSTANCE.XTestFakeButtonEvent(display, button, press ? 1 : 0, 0); + } } diff --git a/src/main/java/mousemaster/platform/linux/LinuxOverlay.java b/src/main/java/mousemaster/platform/linux/LinuxOverlay.java index f60382b7..0c5e88d0 100644 --- a/src/main/java/mousemaster/platform/linux/LinuxOverlay.java +++ b/src/main/java/mousemaster/platform/linux/LinuxOverlay.java @@ -220,7 +220,10 @@ public void handleKeyPress(String keyString) { String hintLabel = getHintLabel(hint); if (hintLabel.equals(key)) { logger.info("TEST: Hint '{}' selected at position ({}, {})", - key, (int)hint.centerX(), (int)hint.centerY()); + key, (int) hint.centerX(), (int) hint.centerY()); + if (platform != null) { + platform.mouse().synchronousMoveTo((int) hint.centerX(), (int) hint.centerY()); + } hideHintMesh(); return; } diff --git a/src/main/java/mousemaster/platform/linux/LinuxPlatform.java b/src/main/java/mousemaster/platform/linux/LinuxPlatform.java index 56390398..27e2b017 100644 --- a/src/main/java/mousemaster/platform/linux/LinuxPlatform.java +++ b/src/main/java/mousemaster/platform/linux/LinuxPlatform.java @@ -63,7 +63,6 @@ public LinuxPlatform(boolean multipleInstancesAllowed, boolean keyRegurgitationE // Initialize all platform components clock = new LinuxClock(); keyboard = new LinuxKeyboard(); - mouse = new LinuxMouse(); screens = new LinuxScreens(display); overlay = new LinuxOverlay(display); overlay.setPlatform(this); @@ -72,12 +71,14 @@ public LinuxPlatform(boolean multipleInstancesAllowed, boolean keyRegurgitationE console = new LinuxConsole(); keyRegurgitator = new KeyRegurgitator(keyboard); - logger.info("LinuxPlatform initialized successfully"); - - // Get root window for event monitoring + // rootWindow must be obtained before creating LinuxMouse (mouse needs it for XWarpPointer) rootWindow = LibX11.INSTANCE.XDefaultRootWindow(display); logger.info("Root window handle: {}", rootWindow); + mouse = new LinuxMouse(display, rootWindow); + + logger.info("LinuxPlatform initialized successfully"); + if (!isWayland) { LibX11.INSTANCE.XSelectInput(display, rootWindow, LibX11.KeyPressMask | LibX11.KeyReleaseMask); @@ -227,6 +228,8 @@ public void shutdown() { keyboardSimulator.stop(); } + mouse.destroy(); + if (display != null) { LibX11.INSTANCE.XCloseDisplay(display); logger.info("X11 display closed"); From 15b973a80d5b88e544313e1126e8c972821a8855 Mon Sep 17 00:00:00 2001 From: SudoWatson Date: Sat, 11 Jul 2026 16:48:11 -0400 Subject: [PATCH 07/23] Agnostic comments --- src/main/java/mousemaster/qt/GridWindow.java | 10 ++-------- src/main/java/mousemaster/qt/HintMeshWindow.java | 15 +++------------ 2 files changed, 5 insertions(+), 20 deletions(-) diff --git a/src/main/java/mousemaster/qt/GridWindow.java b/src/main/java/mousemaster/qt/GridWindow.java index b8e302a5..004219df 100644 --- a/src/main/java/mousemaster/qt/GridWindow.java +++ b/src/main/java/mousemaster/qt/GridWindow.java @@ -8,14 +8,8 @@ import mousemaster.Grid; /** - * Platform-agnostic Qt window for displaying the grid overlay. - * Renders grid lines based on Grid configuration. - * - * NOTE: This is a shared, platform-agnostic implementation currently used by Linux. - * Windows has its own implementation using Win32 API (WindowsOverlay.createGridWindow). - * TODO: Consider refactoring Windows to use this shared Qt-based implementation - * instead of platform-specific Win32 rendering, which would reduce code duplication - * and improve maintainability. + * Cross-platform Qt grid overlay. Currently used by Linux; Windows has its own Win32-based + * implementation. Planned to consolidate in a future PR. */ public class GridWindow extends TransparentWindow { diff --git a/src/main/java/mousemaster/qt/HintMeshWindow.java b/src/main/java/mousemaster/qt/HintMeshWindow.java index c65b890c..38d1cbad 100644 --- a/src/main/java/mousemaster/qt/HintMeshWindow.java +++ b/src/main/java/mousemaster/qt/HintMeshWindow.java @@ -9,18 +9,9 @@ import java.util.List; /** - * Platform-agnostic Qt window for displaying hint mesh overlay. - * Renders letter labels at hint positions. - * - * NOTE: This is a shared, platform-agnostic implementation currently used by Linux. - * Windows has its own more sophisticated implementation using QLabel widgets, pixmap caching, - * and animations (see WindowsOverlay.HintMeshWindow record and ClearBackgroundQLabel class). - * TODO: Consider whether to: - * 1. Enhance this shared version with Windows' advanced features (animations, caching) - * and migrate Windows to use it, OR - * 2. Keep this as a simple version for basic platforms and maintain Windows' advanced version - * The former would reduce code duplication but requires careful refactoring of Windows' complex - * hint rendering pipeline. + * Cross-platform Qt hint mesh overlay. Currently used by Linux; Windows has a more + * sophisticated implementation with per-screen windows, pixmap caching, and animations. + * Planned to consolidate in a future PR once this class is feature-complete. */ public class HintMeshWindow extends TransparentWindow { From a5c246c76b2413d5c3b305552c13c892eeb323ea Mon Sep 17 00:00:00 2001 From: SudoWatson Date: Tue, 14 Jul 2026 18:49:11 -0400 Subject: [PATCH 08/23] Mouse movement but better --- .../mousemaster/platform/linux/LibUinput.java | 43 +++- .../platform/linux/LinuxEvdev.java | 181 +++++++++++++ .../platform/linux/LinuxKeyboard.java | 49 +++- .../mousemaster/platform/linux/LinuxMain.java | 12 + .../platform/linux/LinuxOverlay.java | 148 +---------- .../platform/linux/LinuxPlatform.java | 126 ++------- .../platform/linux/LinuxVirtualKey.java | 242 ++++++++++++++++++ 7 files changed, 544 insertions(+), 257 deletions(-) create mode 100644 src/main/java/mousemaster/platform/linux/LinuxEvdev.java create mode 100644 src/main/java/mousemaster/platform/linux/LinuxVirtualKey.java diff --git a/src/main/java/mousemaster/platform/linux/LibUinput.java b/src/main/java/mousemaster/platform/linux/LibUinput.java index a5162eb1..57802a39 100644 --- a/src/main/java/mousemaster/platform/linux/LibUinput.java +++ b/src/main/java/mousemaster/platform/linux/LibUinput.java @@ -58,8 +58,9 @@ public class LibUinput { static final int INPUT_EVENT_SIZE = 24; static final String UINPUT_PATH = "/dev/uinput"; - // Distinct name lets the evdev keyboard read-loop filter out this virtual device + // Distinct names let the evdev read-loop filter out these virtual devices static final String DEVICE_NAME = "mousemaster-mouse"; + static final String KEYBOARD_DEVICE_NAME = "mousemaster-kb"; interface CLib extends Library { CLib INSTANCE = Native.load("c", CLib.class); @@ -70,6 +71,44 @@ interface CLib extends Library { int ioctl(int fd, NativeLong request, int arg); } + static int createKeyboardDevice() { + int fd = CLib.INSTANCE.open(UINPUT_PATH, O_WRONLY | O_NONBLOCK); + if (fd < 0) { + logger.error("Cannot open {} for keyboard device (errno={})", UINPUT_PATH, Native.getLastError()); + return -1; + } + + requireIoctl(fd, UI_SET_EVBIT, EV_KEY, "UI_SET_EVBIT(EV_KEY)"); + requireIoctl(fd, UI_SET_EVBIT, EV_SYN, "UI_SET_EVBIT(EV_SYN)"); + // Enable all standard key codes (1–255 covers the full QWERTY + function + numpad range) + for (int code = 1; code <= 255; code++) + CLib.INSTANCE.ioctl(fd, new NativeLong(UI_SET_KEYBIT), code); + + Memory userDev = new Memory(UINPUT_USER_DEV_SIZE); + userDev.clear(); + byte[] nameBytes = KEYBOARD_DEVICE_NAME.getBytes(StandardCharsets.US_ASCII); + for (int i = 0; i < nameBytes.length && i < 79; i++) + userDev.setByte(i, nameBytes[i]); + userDev.setShort(80, BUS_VIRTUAL); + NativeLong written = CLib.INSTANCE.write(fd, userDev, new NativeLong(UINPUT_USER_DEV_SIZE)); + if (written.longValue() != UINPUT_USER_DEV_SIZE) { + logger.error("uinput_user_dev write failed for keyboard device: wrote {} of {} bytes (errno={})", + written.longValue(), UINPUT_USER_DEV_SIZE, Native.getLastError()); + CLib.INSTANCE.close(fd); + return -1; + } + + int result = CLib.INSTANCE.ioctl(fd, new NativeLong(UI_DEV_CREATE), 0); + if (result < 0) { + logger.error("UI_DEV_CREATE failed for keyboard device (errno={})", Native.getLastError()); + CLib.INSTANCE.close(fd); + return -1; + } + + logger.info("uinput keyboard device '{}' created (fd={})", KEYBOARD_DEVICE_NAME, fd); + return fd; + } + static int createMouseDevice() { int fd = CLib.INSTANCE.open(UINPUT_PATH, O_WRONLY | O_NONBLOCK); if (fd < 0) { @@ -123,7 +162,7 @@ static void destroyDevice(int fd) { if (fd >= 0) { CLib.INSTANCE.ioctl(fd, new NativeLong(UI_DEV_DESTROY), 0); CLib.INSTANCE.close(fd); - logger.info("uinput mouse device destroyed"); + logger.info("uinput device destroyed (fd={})", fd); } } diff --git a/src/main/java/mousemaster/platform/linux/LinuxEvdev.java b/src/main/java/mousemaster/platform/linux/LinuxEvdev.java new file mode 100644 index 00000000..5f207007 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxEvdev.java @@ -0,0 +1,181 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Library; +import com.sun.jna.Memory; +import com.sun.jna.Native; +import com.sun.jna.NativeLong; +import com.sun.jna.Pointer; +import mousemaster.Key; +import mousemaster.KeyEvent; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.ConcurrentLinkedQueue; + +/** + * Reads raw keyboard input from /dev/input/eventX via evdev. + * Uses EVIOCGRAB for exclusive capture so events don't also reach X11. + * Events are queued and consumed on the main thread via pollEvent(). + */ +public class LinuxEvdev { + + private static final Logger logger = LoggerFactory.getLogger(LinuxEvdev.class); + + // _IOW('E', 0x90, int) = (1<<30)|(0x45<<8)|0x90|(4<<16) + private static final long EVIOCGRAB = 0x40044590L; + // _IOC(_IOC_READ=2, 'E', 0x06, 256) = (2<<30)|(0x45<<8)|0x06|(256<<16) + private static final long EVIOCGNAME_256 = 0x81004506L; + + private static final int INPUT_EVENT_SIZE = 24; // timeval(16) + type(2) + code(2) + value(4) + private static final short EV_KEY = 1; + private static final int KEY_RELEASE = 0; + private static final int KEY_PRESS = 1; + // KEY_REPEAT = 2, ignored + + private static final int O_RDONLY = 0; + + interface CLib extends Library { + CLib INSTANCE = Native.load("c", CLib.class); + int open(String path, int flags); + int close(int fd); + NativeLong read(int fd, Pointer buf, NativeLong count); + int ioctl(int fd, NativeLong request, int arg); + int ioctl(int fd, NativeLong request, Pointer buf); + } + + private final ConcurrentLinkedQueue eventQueue = new ConcurrentLinkedQueue<>(); + private final List openFds = new ArrayList<>(); + private volatile boolean running = true; + + public LinuxEvdev() { + List devices = findKeyboardDevices(); + if (devices.isEmpty()) { + logger.warn("No keyboard devices found — run as root or add yourself to the 'input' group"); + return; + } + for (String path : devices) { + openAndGrab(path); + } + } + + private List findKeyboardDevices() { + List result = new ArrayList<>(); + try { + String content = Files.readString(Path.of("/proc/bus/input/devices")); + boolean hasKbd = false; + boolean isVirtual = false; + String eventNode = null; + for (String line : content.lines().toList()) { + if (line.isBlank()) { + if (hasKbd && !isVirtual && eventNode != null) + result.add("/dev/input/" + eventNode); + hasKbd = false; + isVirtual = false; + eventNode = null; + } else if (line.startsWith("S: Sysfs=")) { + // uinput-created devices live under /devices/virtual/ + isVirtual = line.contains("/devices/virtual/"); + } else if (line.startsWith("H: Handlers=")) { + String handlers = line.substring("H: Handlers=".length()); + hasKbd = handlers.contains("kbd"); + for (String token : handlers.split("\\s+")) { + if (token.startsWith("event")) + eventNode = token; + } + } + } + // Handle last block if file doesn't end with blank line + if (hasKbd && !isVirtual && eventNode != null) + result.add("/dev/input/" + eventNode); + } catch (IOException e) { + logger.error("Cannot read /proc/bus/input/devices: {}", e.getMessage()); + } + logger.info("Found {} keyboard device(s): {}", result.size(), result); + return result; + } + + private void openAndGrab(String path) { + int fd = CLib.INSTANCE.open(path, O_RDONLY); + if (fd < 0) { + logger.warn("Cannot open {} (errno={}) — skipping", path, Native.getLastError()); + return; + } + + Memory nameBuf = new Memory(256); + nameBuf.clear(); + String name = path; + if (CLib.INSTANCE.ioctl(fd, new NativeLong(EVIOCGNAME_256), nameBuf) >= 0) + name = nameBuf.getString(0); + + if (LibUinput.DEVICE_NAME.equals(name) || LibUinput.KEYBOARD_DEVICE_NAME.equals(name)) { + CLib.INSTANCE.close(fd); + return; + } + + int grabResult = CLib.INSTANCE.ioctl(fd, new NativeLong(EVIOCGRAB), 1); + if (grabResult < 0) { + logger.error("EVIOCGRAB failed for {} '{}' (errno={}). Run as root.", + path, name, Native.getLastError()); + CLib.INSTANCE.close(fd); + return; + } + + logger.info("Grabbed keyboard '{}' ({})", name, path); + openFds.add(fd); + + final String deviceName = name; + Thread t = new Thread(() -> readLoop(fd, deviceName), "evdev-" + deviceName); + t.setDaemon(true); + t.start(); + } + + private void readLoop(int fd, String deviceName) { + Memory buf = new Memory(INPUT_EVENT_SIZE); + while (running) { + buf.clear(); + long n = CLib.INSTANCE.read(fd, buf, new NativeLong(INPUT_EVENT_SIZE)).longValue(); + if (n != INPUT_EVENT_SIZE) { + if (running) + logger.warn("evdev read returned {} for '{}', stopping", n, deviceName); + break; + } + + short type = buf.getShort(16); + short code = buf.getShort(18); + int value = buf.getInt(20); + + if (type == EV_KEY && (value == KEY_PRESS || value == KEY_RELEASE)) { + int keycode = code & 0xFFFF; + Key key = LinuxVirtualKey.fromEvdevCode(keycode); + if (key != null) { + Instant now = Instant.now(); + eventQueue.add(value == KEY_PRESS + ? new KeyEvent.PressKeyEvent(now, key) + : new KeyEvent.ReleaseKeyEvent(now, key)); + } else { + logger.debug("No Key mapping for evdev code {}", keycode); + } + } + } + } + + /** Called from the main thread in pumpEvents(). */ + public KeyEvent pollEvent() { + return eventQueue.poll(); + } + + public void destroy() { + running = false; + for (int fd : openFds) { + CLib.INSTANCE.ioctl(fd, new NativeLong(EVIOCGRAB), 0); + CLib.INSTANCE.close(fd); // causes blocking read() to return -1, ending the thread + } + openFds.clear(); + } +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxKeyboard.java b/src/main/java/mousemaster/platform/linux/LinuxKeyboard.java index 903daa64..e99f4c75 100644 --- a/src/main/java/mousemaster/platform/linux/LinuxKeyboard.java +++ b/src/main/java/mousemaster/platform/linux/LinuxKeyboard.java @@ -1,6 +1,8 @@ package mousemaster.platform.linux; import mousemaster.Key; +import mousemaster.MacroMoveDestination; +import mousemaster.ResolvedKeyMacroMove; import mousemaster.ResolvedMacroMove; import mousemaster.platform.KeyboardController; import org.slf4j.Logger; @@ -9,47 +11,74 @@ import java.util.List; /** - * Stub implementation of KeyboardController for Milestone 1. - * TODO: Implement X11 keyboard event reading and injection for Milestone 2. + * Implements keyboard passthrough via a uinput virtual keyboard device. + * + * Physical keys are exclusively grabbed by LinuxEvdev (EVIOCGRAB). Keys that the + * combo engine does not consume are re-emitted here so other apps see them normally. + * Macro/regurgitated keys are also injected here. The uinput device is named + * LibUinput.KEYBOARD_DEVICE_NAME so the evdev reader skips it (no feedback loop). */ public class LinuxKeyboard implements KeyboardController { private static final Logger logger = LoggerFactory.getLogger(LinuxKeyboard.class); + private final int uinputFd; + + public LinuxKeyboard(int uinputFd) { + this.uinputFd = uinputFd; + } + + public void destroy() { + LibUinput.destroyDevice(uinputFd); + } + @Override public void update(double delta) { - // TODO: Process keyboard events from X11 } @Override public void reset() { - // TODO: Reset keyboard state } @Override public void sendInputMoves(List moves, boolean startRepeat) { - // TODO: Send keyboard input via XTest - logger.debug("sendInputMoves() called with {} moves", moves.size()); + for (ResolvedMacroMove move : moves) { + switch (move) { + case ResolvedKeyMacroMove km -> { + if (km.destination() == MacroMoveDestination.OS) + emitKey(km.key(), km.press() ? 1 : 0); + } + default -> logger.warn("sendInputMoves: unsupported move type {}", move.getClass().getSimpleName()); + } + } } @Override public void keyPressedNotEaten(Key key) { - // TODO: Handle key press that wasn't consumed + emitKey(key, 1); } @Override public void keyReleasedNotEaten(Key key) { - // TODO: Handle key release that wasn't consumed + emitKey(key, 0); } @Override public void recordEarlyReleaseForQueuedPress(Key key) { - // TODO: Track early releases + // No send queue on Linux — writes are synchronous, nothing to track } @Override public void clearEarlyReleaseForQueuedPress(Key key) { - // TODO: Clear early release tracking } + private void emitKey(Key key, int value) { + Integer code = LinuxVirtualKey.toEvdevCode(key); + if (code == null) { + logger.warn("No evdev code for key {}, cannot emit", key); + return; + } + LibUinput.writeInputEvent(uinputFd, LibUinput.EV_KEY, code, value); + LibUinput.writeInputEvent(uinputFd, LibUinput.EV_SYN, LibUinput.SYN_REPORT, 0); + } } diff --git a/src/main/java/mousemaster/platform/linux/LinuxMain.java b/src/main/java/mousemaster/platform/linux/LinuxMain.java index a6aa6b92..24bb7734 100644 --- a/src/main/java/mousemaster/platform/linux/LinuxMain.java +++ b/src/main/java/mousemaster/platform/linux/LinuxMain.java @@ -48,6 +48,18 @@ public static void main(String[] args) throws InterruptedException, IOException System.exit(0); }).start(); } + Thread failsafe = new Thread(() -> { + try { + Thread.sleep(60_000); + } catch (InterruptedException ignored) { + return; + } + logger.warn("60-second failsafe triggered — forcing exit to release keyboard grab"); + System.exit(0); + }, "failsafe-shutdown"); + failsafe.setDaemon(true); + failsafe.start(); + Platform platform = createPlatform(options.multipleInstancesAllowed(), options.keyRegurgitationEnabled(), options.pauseOnError()); logger.info("mousemaster v" + version + " (" + commitId + ") [Linux]"); diff --git a/src/main/java/mousemaster/platform/linux/LinuxOverlay.java b/src/main/java/mousemaster/platform/linux/LinuxOverlay.java index 0c5e88d0..1675180f 100644 --- a/src/main/java/mousemaster/platform/linux/LinuxOverlay.java +++ b/src/main/java/mousemaster/platform/linux/LinuxOverlay.java @@ -12,82 +12,24 @@ import java.util.Set; /** - * Linux implementation of the Overlay interface. - * Currently implements basic grid display for Milestone 1. - * TODO: Full implementation of zoom, hint mesh, and indicator features. + * Linux overlay implementation. Delegates rendering to Qt-based GridWindow and HintMeshWindow. + * Zoom and indicator features are stubs pending future implementation. */ public class LinuxOverlay implements Overlay { private static final Logger logger = LoggerFactory.getLogger(LinuxOverlay.class); private final Pointer display; - private Grid currentGrid; - private boolean showingGrid = false; private Runnable messagePump; private GridWindow gridWindow; private HintMeshWindow hintMeshWindow; - private HintMesh currentHintMesh; - private LinuxPlatform platform; - - // TEMPORARY: Test mode to auto-display hints after 1 second - private double elapsedTime = 0.0; - private boolean testGridShown = false; - private boolean testGridHidden = false; public LinuxOverlay(Pointer display) { this.display = display; - logger.info("LinuxOverlay initialized"); - } - - public void setPlatform(LinuxPlatform platform) { - this.platform = platform; } @Override public void update(double delta) { - elapsedTime += delta; - - // TEMPORARY: Auto-display test grid after 1 second - if (!testGridShown && elapsedTime >= 1.0) { - logger.info("TEST MODE: Auto-displaying grid after 1 second"); - displayTestGrid(); - testGridShown = true; - } - - // TEMPORARY: Auto-hide test hints after 6 seconds (5 seconds after showing) - if (testGridShown && !testGridHidden && elapsedTime >= 6.0) { - logger.info("TEST MODE: Auto-hiding hints after 5 seconds of display"); - hideHintMesh(); - testGridHidden = true; - } - } - - // TEMPORARY: Test method to display hint mesh without keyboard input - private void displayTestGrid() { - java.util.List hints = java.util.List.of( - new Hint(400, 300, 100, 100, java.util.List.of(new Key(null, null, "A"))), - new Hint(800, 300, 100, 100, java.util.List.of(new Key(null, null, "B"))), - new Hint(1200, 300, 100, 100, java.util.List.of(new Key(null, null, "C"))), - new Hint(400, 700, 100, 100, java.util.List.of(new Key(null, null, "D"))), - new Hint(800, 700, 100, 100, java.util.List.of(new Key(null, null, "E"))), - new Hint(1200, 700, 100, 100, java.util.List.of(new Key(null, null, "F"))) - ); - - HintMesh testHintMesh = new HintMesh( - true, - hints, - 0, - java.util.List.of(), - null, - null - ); - - setHintMesh(testHintMesh, null); - logger.info("TEST MODE: Hint mesh with letters A-F should now be visible on screen"); - - if (platform != null) { - platform.grabKeyboard(); - } } @Override @@ -127,7 +69,6 @@ public Rectangle activeWindowRectangle(double widthPct, double heightPct, @Override public void setIndicator(Indicator indicator, boolean fadeAnimationEnabled, Duration fadeAnimationDuration, boolean allowFade) { - logger.debug("setIndicator() called"); } @Override @@ -137,110 +78,37 @@ public void hideIndicator(boolean allowFade) { @Override public void setGrid(Grid grid) { - logger.info("setGrid() called: columns={}, rows={}", grid.columnCount(), grid.rowCount()); - this.currentGrid = grid; - this.showingGrid = true; - - if (gridWindow == null) { + if (gridWindow == null) gridWindow = new GridWindow(); - logger.debug("Created new GridWindow"); - } - gridWindow.setGrid(grid); - logger.info("Grid displayed: {}x{} at ({},{}), size {}x{}", + logger.debug("Grid displayed: {}x{} at ({},{}) size {}x{}", grid.columnCount(), grid.rowCount(), grid.x(), grid.y(), grid.width(), grid.height()); } @Override public void hideGrid() { - logger.debug("hideGrid() called"); - this.showingGrid = false; - this.currentGrid = null; - - if (gridWindow != null) { + if (gridWindow != null) gridWindow.clearGrid(); - logger.debug("Grid window hidden"); - } } @Override public void setHintMesh(HintMesh hintMesh, Zoom zoom) { - logger.debug("setHintMesh() called with {} hints", hintMesh.hints().size()); - - this.currentHintMesh = hintMesh; - - if (hintMeshWindow == null) { + if (hintMeshWindow == null) hintMeshWindow = new HintMeshWindow(); - logger.debug("Created new HintMeshWindow"); - } - hintMeshWindow.setHintMesh(hintMesh); - logger.info("Hint mesh displayed with {} hints", hintMesh.hints().size()); - - if (platform != null) { - platform.grabKeyboard(); - } + logger.debug("Hint mesh displayed with {} hints", hintMesh.hints().size()); } @Override public void setHintMesh(HintMesh hintMesh, Zoom zoom, boolean hintMatch) { - logger.debug("setHintMesh() called with hintMatch={}", hintMatch); setHintMesh(hintMesh, zoom); } @Override public void hideHintMesh() { - logger.debug("hideHintMesh() called"); - - this.currentHintMesh = null; - - if (hintMeshWindow != null) { + if (hintMeshWindow != null) hintMeshWindow.clearHints(); - logger.debug("Hint mesh window hidden"); - } - - if (platform != null) { - platform.ungrabKeyboard(); - } - } - - /** - * TEMPORARY: Test method to handle keypresses while hints are showing. - * In full implementation, this would go through KeyboardManager. - */ - public void handleKeyPress(String keyString) { - if (currentHintMesh == null || !currentHintMesh.visible()) { - return; - } - - String key = keyString.toUpperCase(); - - for (Hint hint : currentHintMesh.hints()) { - String hintLabel = getHintLabel(hint); - if (hintLabel.equals(key)) { - logger.info("TEST: Hint '{}' selected at position ({}, {})", - key, (int) hint.centerX(), (int) hint.centerY()); - if (platform != null) { - platform.mouse().synchronousMoveTo((int) hint.centerX(), (int) hint.centerY()); - } - hideHintMesh(); - return; - } - } - - logger.debug("Key '{}' pressed but doesn't match any hint", key); - } - - private String getHintLabel(Hint hint) { - if (hint.keySequence().isEmpty()) { - return ""; - } - Key key = hint.keySequence().get(0); - if (key.character() != null) { - return key.character().toUpperCase(); - } - return ""; } @Override diff --git a/src/main/java/mousemaster/platform/linux/LinuxPlatform.java b/src/main/java/mousemaster/platform/linux/LinuxPlatform.java index 27e2b017..c63cfb6f 100644 --- a/src/main/java/mousemaster/platform/linux/LinuxPlatform.java +++ b/src/main/java/mousemaster/platform/linux/LinuxPlatform.java @@ -6,12 +6,12 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; +import java.time.Instant; import java.util.List; /** - * Linux platform implementation. - * Milestone 1: Basic structure with stubs - focuses on grid display. - * TODO: Implement full keyboard/mouse hooks for Milestones 2-3. + * Linux platform implementation. Wires together evdev input capture, uinput passthrough, + * X11 mouse control, and Qt overlay rendering. */ public class LinuxPlatform implements Platform { @@ -28,13 +28,13 @@ public class LinuxPlatform implements Platform { private final LinuxConsole console; private final KeyRegurgitator keyRegurgitator; + private final LinuxEvdev evdev; private KeyboardLayout activeKeyboardLayout; private MouseManager mouseManager; private KeyboardManager keyboardManager; private List mousePositionListeners; private ModeMap modeMap; private long rootWindow; - private boolean keyboardGrabbed = false; private final boolean isWayland; private LinuxKeyboardSimulator keyboardSimulator; @@ -62,10 +62,10 @@ public LinuxPlatform(boolean multipleInstancesAllowed, boolean keyRegurgitationE // Initialize all platform components clock = new LinuxClock(); - keyboard = new LinuxKeyboard(); + int uinputKeyboardFd = LibUinput.createKeyboardDevice(); + keyboard = new LinuxKeyboard(uinputKeyboardFd); screens = new LinuxScreens(display); overlay = new LinuxOverlay(display); - overlay.setPlatform(this); uiAutomation = new LinuxUiAutomation(); activeAppFinder = new LinuxActiveAppFinder(); console = new LinuxConsole(); @@ -76,18 +76,10 @@ public LinuxPlatform(boolean multipleInstancesAllowed, boolean keyRegurgitationE logger.info("Root window handle: {}", rootWindow); mouse = new LinuxMouse(display, rootWindow); + evdev = new LinuxEvdev(); logger.info("LinuxPlatform initialized successfully"); - if (!isWayland) { - LibX11.INSTANCE.XSelectInput(display, rootWindow, - LibX11.KeyPressMask | LibX11.KeyReleaseMask); - logger.info("XSelectInput called with mask: {}", - (LibX11.KeyPressMask | LibX11.KeyReleaseMask)); - LibX11.INSTANCE.XFlush(display); - logger.info("Keyboard event monitoring setup on root window"); - } - // Default to US QWERTY layout until XKB detection is implemented (Milestone 3) activeKeyboardLayout = KeyboardLayout.keyboardLayoutByIdentifier.get("00000409"); if (activeKeyboardLayout == null && !KeyboardLayout.keyboardLayoutByIdentifier.isEmpty()) { @@ -105,97 +97,21 @@ public void update(double delta) { @Override public void pumpEvents() { if (isWayland && keyboardSimulator != null && keyboardSimulator.hasKeys()) { - String key = keyboardSimulator.pollKey(); - while (key != null) { - logger.info("Simulated keypress: {}", key); - overlay.handleKeyPress(key); - key = keyboardSimulator.pollKey(); - } - } - - int pending = LibX11.INSTANCE.XPending(display); - if (pending > 0 && !isWayland) { - logger.debug("X11 events pending: {}", pending); - } - - int eventCount = 0; - while (LibX11.INSTANCE.XPending(display) > 0) { - LibX11.XEvent event = new LibX11.XEvent(); - LibX11.INSTANCE.XNextEvent(display, event); - eventCount++; - - if (!isWayland) { - if (event.type == LibX11.KeyPress) { - LibX11.XKeyEvent keyEvent = event.getKeyEvent(); - long keysym = LibX11.INSTANCE.XLookupKeysym(keyEvent, 0); - String keyString = LibX11.INSTANCE.XKeysymToString(keysym); - - logger.info("KeyPress detected: {} (keycode: {}, state: {}, window: {})", - keyString, keyEvent.keycode, keyEvent.state, keyEvent.window); - - if (keyString != null) { - overlay.handleKeyPress(keyString); - } - } else if (event.type == LibX11.KeyRelease) { - LibX11.XKeyEvent keyEvent = event.getKeyEvent(); - long keysym = LibX11.INSTANCE.XLookupKeysym(keyEvent, 0); - String keyString = LibX11.INSTANCE.XKeysymToString(keysym); - logger.debug("KeyRelease: {} (keycode: {}, window: {})", - keyString, keyEvent.keycode, keyEvent.window); - } + String keysym = keyboardSimulator.pollKey(); + while (keysym != null) { + Key key = LinuxVirtualKey.fromKeysym(keysym); + if (key != null && keyboardManager != null) + keyboardManager.keyEvent(new KeyEvent.PressKeyEvent(Instant.now(), key)); + keysym = keyboardSimulator.pollKey(); } } - if (eventCount > 0 && !isWayland) { - logger.trace("Processed {} X11 events total", eventCount); - } - } - - public void grabKeyboard() { - if (isWayland) { - logger.info("Running on Wayland - using keyboard simulator instead of X11 grab"); - logger.info("Type letters in the terminal and press Enter to simulate keypresses"); - keyboardGrabbed = true; - return; - } - - if (!keyboardGrabbed) { - logger.info("Attempting to grab keyboard on root window: {}", rootWindow); - LibX11.INSTANCE.XFlush(display); - - int result = LibX11.INSTANCE.XGrabKeyboard(display, rootWindow, 1, - LibX11.GrabModeAsync, LibX11.GrabModeAsync, LibX11.CurrentTime); - - LibX11.INSTANCE.XFlush(display); - logger.info("XGrabKeyboard returned: {}", result); - - if (result == LibX11.GrabSuccess) { - keyboardGrabbed = true; - logger.info("Keyboard grabbed successfully on window {}", rootWindow); - pumpEvents(); - } else { - String errorMsg = switch (result) { - case 1 -> "AlreadyGrabbed"; - case 2 -> "GrabInvalidTime"; - case 3 -> "GrabNotViewable"; - case 4 -> "GrabFrozen"; - default -> "Unknown error " + result; - }; - logger.error("Failed to grab keyboard: {} (code: {})", errorMsg, result); - } - } else { - logger.debug("Keyboard already grabbed"); - } - } - - public void ungrabKeyboard() { - if (keyboardGrabbed) { - if (!isWayland) { - LibX11.INSTANCE.XUngrabKeyboard(display, LibX11.CurrentTime); - LibX11.INSTANCE.XFlush(display); - } - keyboardGrabbed = false; - logger.info("Keyboard ungrabbed"); + KeyEvent event = evdev.pollEvent(); + while (event != null) { + logger.debug("evdev: {}", event); + if (keyboardManager != null) + keyboardManager.keyEvent(event); + event = evdev.pollEvent(); } } @@ -210,13 +126,11 @@ public void sleep() throws InterruptedException { public void reset(MouseManager mouseManager, KeyboardManager keyboardManager, ModeMap modeMap, List mousePositionListeners, KeyboardLayout activeKeyboardLayout) { - logger.debug("reset() called"); this.mouseManager = mouseManager; this.keyboardManager = keyboardManager; this.mousePositionListeners = mousePositionListeners; this.modeMap = modeMap; this.activeKeyboardLayout = activeKeyboardLayout; - overlay.setMessagePump(this::pumpEvents); } @@ -228,6 +142,8 @@ public void shutdown() { keyboardSimulator.stop(); } + evdev.destroy(); + keyboard.destroy(); mouse.destroy(); if (display != null) { diff --git a/src/main/java/mousemaster/platform/linux/LinuxVirtualKey.java b/src/main/java/mousemaster/platform/linux/LinuxVirtualKey.java new file mode 100644 index 00000000..2ffef197 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LinuxVirtualKey.java @@ -0,0 +1,242 @@ +package mousemaster.platform.linux; + +import mousemaster.Key; + +import java.util.HashMap; +import java.util.Map; + +public class LinuxVirtualKey { + + private static final Map KEYSYM_TO_KEY = Map.ofEntries( + // Modifiers + Map.entry("Alt_L", Key.leftalt), + Map.entry("Alt_R", Key.rightalt), + Map.entry("Shift_L", Key.leftshift), + Map.entry("Shift_R", Key.rightshift), + Map.entry("Control_L", Key.leftctrl), + Map.entry("Control_R", Key.rightctrl), + Map.entry("Super_L", Key.leftwin), + Map.entry("Super_R", Key.rightwin), + // Navigation / editing + Map.entry("Return", Key.enter), + Map.entry("Escape", Key.esc), + Map.entry("BackSpace", Key.backspace), + Map.entry("Tab", Key.tab), + Map.entry("space", Key.space), + Map.entry("Delete", Key.del), + Map.entry("Insert", Key.insert), + Map.entry("Home", Key.home), + Map.entry("End", Key.end), + Map.entry("Prior", Key.pageup), + Map.entry("Next", Key.pagedown), + Map.entry("Up", Key.uparrow), + Map.entry("Down", Key.downarrow), + Map.entry("Left", Key.leftarrow), + Map.entry("Right", Key.rightarrow), + // Locks / misc + Map.entry("Caps_Lock", Key.capslock), + Map.entry("Num_Lock", Key.numlock), + Map.entry("Scroll_Lock", Key.scrolllock), + Map.entry("Pause", Key.pause), + Map.entry("Print", Key.printscreen), + Map.entry("Menu", Key.menu), + // Function keys + Map.entry("F1", Key.f1), + Map.entry("F2", Key.f2), + Map.entry("F3", Key.f3), + Map.entry("F4", Key.f4), + Map.entry("F5", Key.f5), + Map.entry("F6", Key.f6), + Map.entry("F7", Key.f7), + Map.entry("F8", Key.f8), + Map.entry("F9", Key.f9), + Map.entry("F10", Key.f10), + Map.entry("F11", Key.f11), + Map.entry("F12", Key.f12), + Map.entry("F13", Key.f13), + Map.entry("F14", Key.f14), + Map.entry("F15", Key.f15), + Map.entry("F16", Key.f16), + Map.entry("F17", Key.f17), + Map.entry("F18", Key.f18), + Map.entry("F19", Key.f19), + Map.entry("F20", Key.f20), + Map.entry("F21", Key.f21), + Map.entry("F22", Key.f22), + Map.entry("F23", Key.f23), + Map.entry("F24", Key.f24), + // Numpad + Map.entry("KP_0", Key.numpad0), + Map.entry("KP_1", Key.numpad1), + Map.entry("KP_2", Key.numpad2), + Map.entry("KP_3", Key.numpad3), + Map.entry("KP_4", Key.numpad4), + Map.entry("KP_5", Key.numpad5), + Map.entry("KP_6", Key.numpad6), + Map.entry("KP_7", Key.numpad7), + Map.entry("KP_8", Key.numpad8), + Map.entry("KP_9", Key.numpad9), + Map.entry("KP_Multiply", Key.numpadmultiply), + Map.entry("KP_Add", Key.numpadadd), + Map.entry("KP_Subtract", Key.numpadsubtract), + Map.entry("KP_Decimal", Key.numpaddecimal), + Map.entry("KP_Divide", Key.numpaddivide), + Map.entry("KP_Enter", Key.enter), + // Punctuation with static Key constants + Map.entry("plus", Key.plus), + Map.entry("minus", Key.minus), + Map.entry("underscore", Key.underscore), + Map.entry("bar", Key.pipe), + Map.entry("asciicircum", Key.caret), + Map.entry("braceleft", Key.leftcurlybrace), + Map.entry("braceright", Key.rightcurlybrace), + Map.entry("backslash", Key.backslash), + Map.entry("numbersign", Key.hash) + ); + + // Evdev keycode → Key (assumes QWERTY physical layout for character keys) + private static final Map EVDEV_TO_KEY = new HashMap<>(); + // Key → evdev keycode (reverse of EVDEV_TO_KEY; built after EVDEV_TO_KEY is populated) + private static final Map KEY_TO_EVDEV = new HashMap<>(); + + static { + // Special / static keys + EVDEV_TO_KEY.put(1, Key.esc); + EVDEV_TO_KEY.put(14, Key.backspace); + EVDEV_TO_KEY.put(15, Key.tab); + EVDEV_TO_KEY.put(28, Key.enter); + EVDEV_TO_KEY.put(29, Key.leftctrl); + EVDEV_TO_KEY.put(42, Key.leftshift); + EVDEV_TO_KEY.put(54, Key.rightshift); + EVDEV_TO_KEY.put(56, Key.leftalt); + EVDEV_TO_KEY.put(57, Key.space); + EVDEV_TO_KEY.put(58, Key.capslock); + EVDEV_TO_KEY.put(59, Key.f1); + EVDEV_TO_KEY.put(60, Key.f2); + EVDEV_TO_KEY.put(61, Key.f3); + EVDEV_TO_KEY.put(62, Key.f4); + EVDEV_TO_KEY.put(63, Key.f5); + EVDEV_TO_KEY.put(64, Key.f6); + EVDEV_TO_KEY.put(65, Key.f7); + EVDEV_TO_KEY.put(66, Key.f8); + EVDEV_TO_KEY.put(67, Key.f9); + EVDEV_TO_KEY.put(68, Key.f10); + EVDEV_TO_KEY.put(69, Key.numlock); + EVDEV_TO_KEY.put(70, Key.scrolllock); + EVDEV_TO_KEY.put(71, Key.numpad7); + EVDEV_TO_KEY.put(72, Key.numpad8); + EVDEV_TO_KEY.put(73, Key.numpad9); + EVDEV_TO_KEY.put(74, Key.numpadsubtract); + EVDEV_TO_KEY.put(75, Key.numpad4); + EVDEV_TO_KEY.put(76, Key.numpad5); + EVDEV_TO_KEY.put(77, Key.numpad6); + EVDEV_TO_KEY.put(78, Key.numpadadd); + EVDEV_TO_KEY.put(79, Key.numpad1); + EVDEV_TO_KEY.put(80, Key.numpad2); + EVDEV_TO_KEY.put(81, Key.numpad3); + EVDEV_TO_KEY.put(82, Key.numpad0); + EVDEV_TO_KEY.put(83, Key.numpaddecimal); + EVDEV_TO_KEY.put(87, Key.f11); + EVDEV_TO_KEY.put(88, Key.f12); + EVDEV_TO_KEY.put(96, Key.enter); // KP_ENTER + EVDEV_TO_KEY.put(97, Key.rightctrl); + EVDEV_TO_KEY.put(98, Key.numpaddivide); + EVDEV_TO_KEY.put(99, Key.printscreen); + EVDEV_TO_KEY.put(100, Key.rightalt); + EVDEV_TO_KEY.put(102, Key.home); + EVDEV_TO_KEY.put(103, Key.uparrow); + EVDEV_TO_KEY.put(104, Key.pageup); + EVDEV_TO_KEY.put(105, Key.leftarrow); + EVDEV_TO_KEY.put(106, Key.rightarrow); + EVDEV_TO_KEY.put(107, Key.end); + EVDEV_TO_KEY.put(108, Key.downarrow); + EVDEV_TO_KEY.put(109, Key.pagedown); + EVDEV_TO_KEY.put(110, Key.insert); + EVDEV_TO_KEY.put(111, Key.del); + EVDEV_TO_KEY.put(119, Key.pause); + EVDEV_TO_KEY.put(125, Key.leftwin); + EVDEV_TO_KEY.put(126, Key.rightwin); + EVDEV_TO_KEY.put(127, Key.menu); + EVDEV_TO_KEY.put(183, Key.f13); + EVDEV_TO_KEY.put(184, Key.f14); + EVDEV_TO_KEY.put(185, Key.f15); + EVDEV_TO_KEY.put(186, Key.f16); + EVDEV_TO_KEY.put(187, Key.f17); + EVDEV_TO_KEY.put(188, Key.f18); + EVDEV_TO_KEY.put(189, Key.f19); + EVDEV_TO_KEY.put(190, Key.f20); + EVDEV_TO_KEY.put(191, Key.f21); + EVDEV_TO_KEY.put(192, Key.f22); + EVDEV_TO_KEY.put(193, Key.f23); + EVDEV_TO_KEY.put(194, Key.f24); + EVDEV_TO_KEY.put(55, Key.numpadmultiply); + + // Character keys — physical QWERTY positions mapped to unshifted characters + int[] digitCodes = {11, 2, 3, 4, 5, 6, 7, 8, 9, 10}; // 0-9 + for (int i = 0; i < digitCodes.length; i++) + EVDEV_TO_KEY.put(digitCodes[i], Key.ofCharacter(String.valueOf((char)('0' + i)))); + + String qwerty = "qwertyuiopasdfghjklzxcvbnm"; + int[] letterCodes = { + 16,17,18,19,20,21,22,23,24,25, // q w e r t y u i o p + 30,31,32,33,34,35,36,37,38, // a s d f g h j k l + 44,45,46,47,48,49,50 // z x c v b n m + }; + for (int i = 0; i < letterCodes.length; i++) + EVDEV_TO_KEY.put(letterCodes[i], Key.ofCharacter(String.valueOf(qwerty.charAt(i)))); + + // Punctuation + EVDEV_TO_KEY.put(12, Key.minus); + EVDEV_TO_KEY.put(13, Key.ofCharacter("=")); + EVDEV_TO_KEY.put(26, Key.ofCharacter("[")); + EVDEV_TO_KEY.put(27, Key.ofCharacter("]")); + EVDEV_TO_KEY.put(39, Key.ofCharacter(";")); + EVDEV_TO_KEY.put(40, Key.ofCharacter("'")); + EVDEV_TO_KEY.put(41, Key.ofCharacter("`")); + EVDEV_TO_KEY.put(43, Key.backslash); + EVDEV_TO_KEY.put(51, Key.ofCharacter(",")); + EVDEV_TO_KEY.put(52, Key.ofCharacter(".")); + EVDEV_TO_KEY.put(53, Key.ofCharacter("/")); + + // Build reverse map; lower code wins for keys that share a mapping (e.g. enter=28, kp_enter=96) + EVDEV_TO_KEY.entrySet().stream() + .sorted(Map.Entry.comparingByKey()) + .forEach(e -> KEY_TO_EVDEV.putIfAbsent(e.getValue(), e.getKey())); + } + + /** + * Maps a Linux evdev keycode (EV_KEY code) to a Key. + * Character keys assume a QWERTY physical layout. + */ + public static Key fromEvdevCode(int code) { + return EVDEV_TO_KEY.get(code); + } + + /** + * Maps a Key back to its canonical evdev keycode for uinput injection. + * Returns null if no evdev code is known for the key. + */ + public static Integer toEvdevCode(Key key) { + return KEY_TO_EVDEV.get(key); + } + + /** + * Maps an X11 keysym name (from XKeysymToString) to a Key. + * Single-character keysym names (e.g. "a", "b", "1") are resolved via Key.ofCharacter. + * Returns null if the keysym has no mapping. + */ + public static Key fromKeysym(String keysym) { + if (keysym == null) { + return null; + } + Key mapped = KEYSYM_TO_KEY.get(keysym); + if (mapped != null) { + return mapped; + } + if (keysym.length() == 1) { + return Key.ofCharacter(keysym); + } + return null; + } + +} From c85df26fb6ed7a76acdfd7a09be090f229c148c9 Mon Sep 17 00:00:00 2001 From: SudoWatson Date: Thu, 16 Jul 2026 06:11:13 -0400 Subject: [PATCH 09/23] Clicking on move --- src/main/java/mousemaster/ComboWatcher.java | 6 +- .../java/mousemaster/KeyboardManager.java | 97 ++++++++++++++++--- .../mousemaster/PressKeyEventProcessing.java | 21 +++- .../platform/linux/LinuxMouse.java | 1 + .../platform/linux/LinuxPlatform.java | 57 ++++++++++- 5 files changed, 162 insertions(+), 20 deletions(-) diff --git a/src/main/java/mousemaster/ComboWatcher.java b/src/main/java/mousemaster/ComboWatcher.java index 080733b9..7c3e209b 100644 --- a/src/main/java/mousemaster/ComboWatcher.java +++ b/src/main/java/mousemaster/ComboWatcher.java @@ -579,8 +579,12 @@ public PressKeyEventProcessingSet keyEvent(KeyEvent event) { if (isIgnoredByLeadingWait) processing = PressKeyEventProcessing.ignoredByLeadingWait(leadingWaitEatsEvents); else // isComboPreconditionKey must be true + // The key is eaten speculatively: it may be the first half of a + // chord (e.g. holding leftbutton while pressing a hint key). If no + // combo ends up using it, it is regurgitated on release (see + // KeyboardManager's eatenKeys handling). processing = isPressedComboPreconditionKey ? - PressKeyEventProcessing.partOfPressedComboPreconditionOnly() : + PressKeyEventProcessing.partOfPressedComboPreconditionOnly(true) : PressKeyEventProcessing.partOfUnpressedComboPreconditionOnly(); processingSet = new PressKeyEventProcessingSet( new HashMap<>(Map.of(PressKeyEventProcessingSet.dummyCombo, processing)), diff --git a/src/main/java/mousemaster/KeyboardManager.java b/src/main/java/mousemaster/KeyboardManager.java index 1d061f86..16340f25 100644 --- a/src/main/java/mousemaster/KeyboardManager.java +++ b/src/main/java/mousemaster/KeyboardManager.java @@ -263,7 +263,16 @@ else if (processingSet.isPartOfComboSequence()) { eatenKeys.put(key, new Eat(true, existingEat.processingSet())); } } - if (!mustBeEaten) + // If this key's release is itself part of `regurgitates` (its + // combo failed and buildRegurgitates already queued a synthetic + // press+release for it), don't also tell the platform the real + // release is "not eaten": that would deliver the release twice on + // platforms that actually re-inject not-eaten keys (Linux uinput). + // On Windows this call is a bookkeeping no-op, so this is a no-op + // change there. + boolean releasedByRegurgitation = regurgitates.stream() + .anyMatch(r -> r.key().equals(key) && r.alsoRelease()); + if (!mustBeEaten && !releasedByRegurgitation) macroPlayer.keyReleasedNotEaten(key); return eatAndRegurgitates(mustBeEaten, regurgitates); } @@ -325,6 +334,31 @@ private boolean markOtherKeysOfTheseCombosAsCompleted(List comple processing.mustBeEaten(), true, processing.isComboPreparationBreaker() || forceIsComboPreparationBreaker)); } + // Pressed precondition keys (e.g. _{leftbutton} in _{leftbutton} +hint1key) + // are not part of the sequence itself, so they are tracked under + // PressKeyEventProcessingSet.dummyCombo rather than under combo. Mark them + // completed too, so the completed chord's precondition key is not + // regurgitated once the combo it gated has fired. + for (Key key : combo.precondition().keyPrecondition().pressedKeyPrecondition().allKeys()) { + PressKeyEventProcessingSet processingSet = currentlyPressedKeys.get(key); + if (processingSet == null) { + Eat eat = eatenKeys.get(key); + if (eat != null) + processingSet = eat.processingSet(); + } + if (processingSet == null) + continue; + Map processingByCombo = + processingSet.processingByCombo(); + for (Map.Entry entry : + Set.copyOf(processingByCombo.entrySet())) { + if (entry.getValue().isPartOfPressedComboPreconditionOnly()) + processingByCombo.put(entry.getKey(), + PressKeyEventProcessing.partOfComboSequence( + entry.getValue().mustBeEaten(), true, + forceIsComboPreparationBreaker)); + } + } } return completedCombosHavePressedKeys; } @@ -350,18 +384,12 @@ private List buildRegurgitates(Key filterKey, Key releasingKey, boolean alsoRelease; if (eat.released()) { alsoRelease = true; - if (!retainCombos.isEmpty() && - processingSet.processingByCombo().entrySet().stream() - .anyMatch(e -> retainCombos.contains(e.getKey()) && - e.getValue().mustBeEaten())) + if (isRetainedByCombos(processingSet, eatenKey, retainCombos)) continue; keysToRemove.add(eatenKey); } else { - if (!retainCombos.isEmpty() && - processingSet.processingByCombo().entrySet().stream() - .anyMatch(e -> retainCombos.contains(e.getKey()) && - e.getValue().mustBeEaten())) + if (isRetainedByCombos(processingSet, eatenKey, retainCombos)) continue; alsoRelease = releasingKey != null && releasingKey.equals(eatenKey); } @@ -371,6 +399,34 @@ private List buildRegurgitates(Key filterKey, Key releasingKey, return regurgitates; } + /** + * A key stays retained (not regurgitated) if it is directly part of one of + * retainCombos, or if it is a pressed-precondition-only key (e.g. leftbutton in + * _{leftbutton} +hint1key) required by one of retainCombos. The latter case + * matters because precondition keys are tracked under + * PressKeyEventProcessingSet.dummyCombo rather than under the real combo, so they + * cannot be matched by combo identity alone. + */ + private static boolean isRetainedByCombos(PressKeyEventProcessingSet processingSet, + Key eatenKey, Set retainCombos) { + if (retainCombos.isEmpty()) + return false; + if (processingSet.processingByCombo().entrySet().stream() + .anyMatch(e -> retainCombos.contains(e.getKey()) && + e.getValue().mustBeEaten())) + return true; + if (processingSet.processingByCombo().values().stream() + .anyMatch(PressKeyEventProcessing::isPartOfPressedComboPreconditionOnly)) { + return retainCombos.stream() + .anyMatch(combo -> combo.precondition() + .keyPrecondition() + .pressedKeyPrecondition() + .allKeys() + .contains(eatenKey)); + } + return false; + } + private void addRegurgitate(PressKeyEventProcessingSet processingSet, List regurgitates, Key eatenKey, boolean alsoRelease) { @@ -383,7 +439,8 @@ private void addRegurgitate(PressKeyEventProcessingSet processingSet, processingSet.processingByCombo().entrySet())) { Combo combo = entry.getKey(); PressKeyEventProcessing processing = entry.getValue(); - if (processing.isPartOfComboSequence()) + if (processing.isPartOfComboSequence() || + processing.isPartOfPressedComboPreconditionOnly()) processingSet.processingByCombo() .put(combo, PressKeyEventProcessing.partOfComboSequence( @@ -516,9 +573,23 @@ private List handleDeadEatingCombos(Set deadCombos, private void clearFullyCompletedEatenKeys() { eatenKeys.entrySet().removeIf(entry -> entry.getValue().processingSet().processingByCombo().values().stream() - .allMatch(p -> !p.isPartOfComboSequence() || - p.isPartOfCompletedComboSequence() || - !p.mustBeEaten())); + .allMatch(KeyboardManager::isClearedProcessing)); + } + + /** + * A precondition-only key (e.g. leftbutton in _{leftbutton} +hint1key) is not + * part of any combo's sequence, so isPartOfComboSequence() is false for it even + * while it must still be eaten and tracked (its gated combo has not completed or + * failed yet). Treat it as clearable only once it stops needing to be eaten; + * markOtherKeysOfTheseCombosAsCompleted rewrites it to a completed sequence entry + * once its combo fires. + */ + private static boolean isClearedProcessing(PressKeyEventProcessing p) { + if (p.isPartOfPressedComboPreconditionOnly()) + return !p.mustBeEaten(); + return !p.isPartOfComboSequence() || + p.isPartOfCompletedComboSequence() || + !p.mustBeEaten(); } } diff --git a/src/main/java/mousemaster/PressKeyEventProcessing.java b/src/main/java/mousemaster/PressKeyEventProcessing.java index d2b8d026..5be3fc19 100644 --- a/src/main/java/mousemaster/PressKeyEventProcessing.java +++ b/src/main/java/mousemaster/PressKeyEventProcessing.java @@ -8,6 +8,7 @@ public enum PressKeyEventProcessing { PART_OF_COMPLETED_COMBO_SEQUENCE_MUST_NOT_BE_EATEN, PART_OF_COMPLETED_COMBO_SEQUENCE_MUST_BE_EATEN, PART_OF_PRESSED_COMBO_PRECONDITION_ONLY, // "Only" means it is not part of a combo sequence (it is just part of a combo precondition). + PART_OF_PRESSED_COMBO_PRECONDITION_ONLY_MUST_BE_EATEN, // Same as above, but the key must be eaten speculatively (e.g. space held down for a chord whose other key hasn't been pressed yet). PART_OF_UNPRESSED_COMBO_PRECONDITION_ONLY, IGNORED_BY_LEADING_WAIT_MUST_NOT_BE_EATEN, IGNORED_BY_LEADING_WAIT_MUST_BE_EATEN, @@ -25,7 +26,8 @@ public boolean mustBeEaten() { this == IGNORED_BY_LEADING_WAIT_MUST_BE_EATEN || this == HINT_UNDO_MUST_BE_EATEN || this == UNSWALLOWED_HINT_END_MUST_BE_EATEN || - this == UNUSED_HINT_SELECTION_KEY_MUST_BE_EATEN; + this == UNUSED_HINT_SELECTION_KEY_MUST_BE_EATEN || + this == PART_OF_PRESSED_COMBO_PRECONDITION_ONLY_MUST_BE_EATEN; } public boolean handled() { @@ -40,10 +42,19 @@ public boolean handled() { this == UNSWALLOWED_HINT_END_MUST_BE_EATEN || this == UNUSED_HINT_SELECTION_KEY_MUST_BE_EATEN || this == PART_OF_PRESSED_COMBO_PRECONDITION_ONLY || + this == PART_OF_PRESSED_COMBO_PRECONDITION_ONLY_MUST_BE_EATEN || this == IGNORED_BY_LEADING_WAIT_MUST_NOT_BE_EATEN || this == IGNORED_BY_LEADING_WAIT_MUST_BE_EATEN; } + // Note: PART_OF_PRESSED_COMBO_PRECONDITION_ONLY_MUST_BE_EATEN is deliberately NOT + // part of isPartOfComboSequence(): it is tracked under the empty dummyCombo + // sequence, and ComboWatcher.update() uses isPartOfComboSequence() to require an + // active sequence match against the preparation. Since dummyCombo's sequence can + // never match, that would make preparationIsNotPrefixAnymore fire on the very next + // tick, force-regurgitating the precondition key before the rest of the chord is + // typed. Instead, KeyboardManager treats isPartOfPressedComboPreconditionOnly() + // as its own case in clearFullyCompletedEatenKeys/buildRegurgitates. public boolean isPartOfComboSequence() { return this == PART_OF_COMBO_SEQUENCE_MUST_NOT_BE_EATEN || this == PART_OF_COMBO_SEQUENCE_MUST_BE_EATEN || @@ -62,7 +73,8 @@ public boolean isPartOfCompletedComboSequence() { } public boolean isPartOfPressedComboPreconditionOnly() { - return this == PART_OF_PRESSED_COMBO_PRECONDITION_ONLY; + return this == PART_OF_PRESSED_COMBO_PRECONDITION_ONLY || + this == PART_OF_PRESSED_COMBO_PRECONDITION_ONLY_MUST_BE_EATEN; } public boolean isPartOfUnpressedComboPreconditionOnly() { @@ -149,8 +161,9 @@ public static PressKeyEventProcessing ignoredByLeadingWait(boolean mustBeEaten) IGNORED_BY_LEADING_WAIT_MUST_NOT_BE_EATEN; } - public static PressKeyEventProcessing partOfPressedComboPreconditionOnly() { - return PART_OF_PRESSED_COMBO_PRECONDITION_ONLY; + public static PressKeyEventProcessing partOfPressedComboPreconditionOnly(boolean mustBeEaten) { + return mustBeEaten ? PART_OF_PRESSED_COMBO_PRECONDITION_ONLY_MUST_BE_EATEN : + PART_OF_PRESSED_COMBO_PRECONDITION_ONLY; } public static PressKeyEventProcessing partOfUnpressedComboPreconditionOnly() { diff --git a/src/main/java/mousemaster/platform/linux/LinuxMouse.java b/src/main/java/mousemaster/platform/linux/LinuxMouse.java index 2d29ec95..920b4c6f 100644 --- a/src/main/java/mousemaster/platform/linux/LinuxMouse.java +++ b/src/main/java/mousemaster/platform/linux/LinuxMouse.java @@ -133,5 +133,6 @@ public void showCursor() { private void buttonEvent(int button, boolean press) { LibXTest.INSTANCE.XTestFakeButtonEvent(display, button, press ? 1 : 0, 0); + LibX11.INSTANCE.XFlush(display); } } diff --git a/src/main/java/mousemaster/platform/linux/LinuxPlatform.java b/src/main/java/mousemaster/platform/linux/LinuxPlatform.java index c63cfb6f..7d38d256 100644 --- a/src/main/java/mousemaster/platform/linux/LinuxPlatform.java +++ b/src/main/java/mousemaster/platform/linux/LinuxPlatform.java @@ -1,6 +1,8 @@ package mousemaster.platform.linux; import com.sun.jna.Pointer; +import com.sun.jna.ptr.IntByReference; +import com.sun.jna.ptr.LongByReference; import mousemaster.*; import mousemaster.platform.*; import org.slf4j.Logger; @@ -36,6 +38,8 @@ public class LinuxPlatform implements Platform { private ModeMap modeMap; private long rootWindow; private final boolean isWayland; + private Integer lastMouseX; + private Integer lastMouseY; private LinuxKeyboardSimulator keyboardSimulator; public LinuxPlatform(boolean multipleInstancesAllowed, boolean keyRegurgitationEnabled) { @@ -101,7 +105,7 @@ public void pumpEvents() { while (keysym != null) { Key key = LinuxVirtualKey.fromKeysym(keysym); if (key != null && keyboardManager != null) - keyboardManager.keyEvent(new KeyEvent.PressKeyEvent(Instant.now(), key)); + handleKeyEvent(new KeyEvent.PressKeyEvent(Instant.now(), key)); keysym = keyboardSimulator.pollKey(); } } @@ -110,18 +114,64 @@ public void pumpEvents() { while (event != null) { logger.debug("evdev: {}", event); if (keyboardManager != null) - keyboardManager.keyEvent(event); + handleKeyEvent(event); event = evdev.pollEvent(); } } + /** + * Unlike WindowsPlatform (where the low-level hook's return value is what + * suppresses a key from reaching the OS), Linux already grabbed the physical + * device exclusively (EVIOCGRAB): nothing reaches other apps unless we write it + * to the uinput device ourselves. So the regurgitates KeyboardManager computes + * (e.g. a precondition key like leftbutton typed back out because the chord it + * was gating never completed) must be explicitly forwarded here, or they are + * silently dropped. + */ + private void handleKeyEvent(KeyEvent event) { + KeyboardManager.EatAndRegurgitates eatAndRegurgitates = keyboardManager.keyEvent(event); + for (KeyboardManager.Regurgitate regurgitate : eatAndRegurgitates.regurgitates()) { + keyRegurgitator.regurgitate(regurgitate, !regurgitate.alsoRelease()); + } + } + @Override public void sleep() throws InterruptedException { pumpEvents(); + notifyMousePositionListenersIfMoved(); Thread.sleep(10); pumpEvents(); } + /** + * Windows notifies MousePositionListener (e.g. MouseManager, used to detect when a + * smooth jump has reached its destination and clear the atomic-command-in-progress + * flag) via its low-level mouse hook, which sees every cursor move including + * synthetic ones. X11 has no such hook, so the position is polled instead. + */ + private void notifyMousePositionListenersIfMoved() { + LongByReference root = new LongByReference(); + LongByReference child = new LongByReference(); + IntByReference rootX = new IntByReference(); + IntByReference rootY = new IntByReference(); + IntByReference winX = new IntByReference(); + IntByReference winY = new IntByReference(); + IntByReference mask = new IntByReference(); + boolean sameScreen = LibX11.INSTANCE.XQueryPointer(display, rootWindow, root, child, + rootX, rootY, winX, winY, mask); + if (!sameScreen) + return; + int x = rootX.getValue(); + int y = rootY.getValue(); + if (lastMouseX != null && lastMouseX == x && lastMouseY != null && lastMouseY == y) + return; + lastMouseX = x; + lastMouseY = y; + for (MousePositionListener listener : mousePositionListeners) { + listener.mouseMoved(x, y); + } + } + @Override public void reset(MouseManager mouseManager, KeyboardManager keyboardManager, ModeMap modeMap, List mousePositionListeners, @@ -132,6 +182,9 @@ public void reset(MouseManager mouseManager, KeyboardManager keyboardManager, this.modeMap = modeMap; this.activeKeyboardLayout = activeKeyboardLayout; overlay.setMessagePump(this::pumpEvents); + lastMouseX = null; + lastMouseY = null; + notifyMousePositionListenersIfMoved(); } @Override From 2ad38082469f10d3835e4858c81a4ef17bf10433 Mon Sep 17 00:00:00 2001 From: SudoWatson Date: Thu, 16 Jul 2026 06:11:20 -0400 Subject: [PATCH 10/23] Cleanup --- src/main/java/mousemaster/ComboWatcher.java | 6 +-- .../java/mousemaster/KeyboardManager.java | 42 ++++++++----------- .../mousemaster/PressKeyEventProcessing.java | 13 +++--- .../platform/linux/LinuxPlatform.java | 21 ++++------ 4 files changed, 34 insertions(+), 48 deletions(-) diff --git a/src/main/java/mousemaster/ComboWatcher.java b/src/main/java/mousemaster/ComboWatcher.java index 7c3e209b..b21345f9 100644 --- a/src/main/java/mousemaster/ComboWatcher.java +++ b/src/main/java/mousemaster/ComboWatcher.java @@ -579,10 +579,8 @@ public PressKeyEventProcessingSet keyEvent(KeyEvent event) { if (isIgnoredByLeadingWait) processing = PressKeyEventProcessing.ignoredByLeadingWait(leadingWaitEatsEvents); else // isComboPreconditionKey must be true - // The key is eaten speculatively: it may be the first half of a - // chord (e.g. holding leftbutton while pressing a hint key). If no - // combo ends up using it, it is regurgitated on release (see - // KeyboardManager's eatenKeys handling). + // Eaten speculatively; regurgitated on release if no combo ends up + // using it (see KeyboardManager's eatenKeys handling). processing = isPressedComboPreconditionKey ? PressKeyEventProcessing.partOfPressedComboPreconditionOnly(true) : PressKeyEventProcessing.partOfUnpressedComboPreconditionOnly(); diff --git a/src/main/java/mousemaster/KeyboardManager.java b/src/main/java/mousemaster/KeyboardManager.java index 16340f25..5019165e 100644 --- a/src/main/java/mousemaster/KeyboardManager.java +++ b/src/main/java/mousemaster/KeyboardManager.java @@ -263,13 +263,9 @@ else if (processingSet.isPartOfComboSequence()) { eatenKeys.put(key, new Eat(true, existingEat.processingSet())); } } - // If this key's release is itself part of `regurgitates` (its - // combo failed and buildRegurgitates already queued a synthetic - // press+release for it), don't also tell the platform the real - // release is "not eaten": that would deliver the release twice on - // platforms that actually re-inject not-eaten keys (Linux uinput). - // On Windows this call is a bookkeeping no-op, so this is a no-op - // change there. + // Avoid double-delivering the release on platforms that re-inject + // not-eaten keys (Linux uinput): don't also report "not eaten" if + // it's already queued as a regurgitate. boolean releasedByRegurgitation = regurgitates.stream() .anyMatch(r -> r.key().equals(key) && r.alsoRelease()); if (!mustBeEaten && !releasedByRegurgitation) @@ -334,11 +330,8 @@ private boolean markOtherKeysOfTheseCombosAsCompleted(List comple processing.mustBeEaten(), true, processing.isComboPreparationBreaker() || forceIsComboPreparationBreaker)); } - // Pressed precondition keys (e.g. _{leftbutton} in _{leftbutton} +hint1key) - // are not part of the sequence itself, so they are tracked under - // PressKeyEventProcessingSet.dummyCombo rather than under combo. Mark them - // completed too, so the completed chord's precondition key is not - // regurgitated once the combo it gated has fired. + // Precondition keys are tracked under dummyCombo, not combo, so they need + // marking separately to avoid being regurgitated once their combo fires. for (Key key : combo.precondition().keyPrecondition().pressedKeyPrecondition().allKeys()) { PressKeyEventProcessingSet processingSet = currentlyPressedKeys.get(key); if (processingSet == null) { @@ -400,12 +393,9 @@ private List buildRegurgitates(Key filterKey, Key releasingKey, } /** - * A key stays retained (not regurgitated) if it is directly part of one of - * retainCombos, or if it is a pressed-precondition-only key (e.g. leftbutton in - * _{leftbutton} +hint1key) required by one of retainCombos. The latter case - * matters because precondition keys are tracked under - * PressKeyEventProcessingSet.dummyCombo rather than under the real combo, so they - * cannot be matched by combo identity alone. + * Precondition-only keys are tracked under dummyCombo, not the real combo, so they + * can't be matched by combo identity alone; check the combo's precondition keys + * directly for that case. */ private static boolean isRetainedByCombos(PressKeyEventProcessingSet processingSet, Key eatenKey, Set retainCombos) { @@ -537,6 +527,12 @@ private List handleDeadEatingCombos(Set deadCombos, Key eatenKey = entry.getKey(); Eat eat = entry.getValue(); PressKeyEventProcessingSet ps = eat.processingSet(); + // Precondition-only keys are tracked under dummyCombo, not a real Combo, so + // they never appear in deadCombos/viableCombos and must be skipped here: + // their own combo's liveness is checked directly (isRetainedByCombos) when + // they're released, not via this dead-combo-identity tracking. + if (ps.isPartOfPressedComboPreconditionOnly()) + continue; // Check if any viable (non-dead) eating combo remains. boolean hasViableEatingCombo = ps.processingByCombo().entrySet() .stream() @@ -577,12 +573,10 @@ private void clearFullyCompletedEatenKeys() { } /** - * A precondition-only key (e.g. leftbutton in _{leftbutton} +hint1key) is not - * part of any combo's sequence, so isPartOfComboSequence() is false for it even - * while it must still be eaten and tracked (its gated combo has not completed or - * failed yet). Treat it as clearable only once it stops needing to be eaten; - * markOtherKeysOfTheseCombosAsCompleted rewrites it to a completed sequence entry - * once its combo fires. + * Precondition-only keys never satisfy isPartOfComboSequence(), so they need their + * own clearable check: clearable once no longer mustBeEaten (rewritten to a + * completed sequence entry by markOtherKeysOfTheseCombosAsCompleted once their + * combo fires). */ private static boolean isClearedProcessing(PressKeyEventProcessing p) { if (p.isPartOfPressedComboPreconditionOnly()) diff --git a/src/main/java/mousemaster/PressKeyEventProcessing.java b/src/main/java/mousemaster/PressKeyEventProcessing.java index 5be3fc19..62d79e65 100644 --- a/src/main/java/mousemaster/PressKeyEventProcessing.java +++ b/src/main/java/mousemaster/PressKeyEventProcessing.java @@ -47,14 +47,11 @@ public boolean handled() { this == IGNORED_BY_LEADING_WAIT_MUST_BE_EATEN; } - // Note: PART_OF_PRESSED_COMBO_PRECONDITION_ONLY_MUST_BE_EATEN is deliberately NOT - // part of isPartOfComboSequence(): it is tracked under the empty dummyCombo - // sequence, and ComboWatcher.update() uses isPartOfComboSequence() to require an - // active sequence match against the preparation. Since dummyCombo's sequence can - // never match, that would make preparationIsNotPrefixAnymore fire on the very next - // tick, force-regurgitating the precondition key before the rest of the chord is - // typed. Instead, KeyboardManager treats isPartOfPressedComboPreconditionOnly() - // as its own case in clearFullyCompletedEatenKeys/buildRegurgitates. + // PART_OF_PRESSED_COMBO_PRECONDITION_ONLY_MUST_BE_EATEN is deliberately NOT part of + // isPartOfComboSequence(): it is tracked under the empty dummyCombo sequence, which + // can never match, so ComboWatcher.update()'s prefix check would immediately + // force-regurgitate the key. KeyboardManager instead handles it via + // isPartOfPressedComboPreconditionOnly() in clearFullyCompletedEatenKeys/buildRegurgitates. public boolean isPartOfComboSequence() { return this == PART_OF_COMBO_SEQUENCE_MUST_NOT_BE_EATEN || this == PART_OF_COMBO_SEQUENCE_MUST_BE_EATEN || diff --git a/src/main/java/mousemaster/platform/linux/LinuxPlatform.java b/src/main/java/mousemaster/platform/linux/LinuxPlatform.java index 7d38d256..82012171 100644 --- a/src/main/java/mousemaster/platform/linux/LinuxPlatform.java +++ b/src/main/java/mousemaster/platform/linux/LinuxPlatform.java @@ -120,13 +120,9 @@ public void pumpEvents() { } /** - * Unlike WindowsPlatform (where the low-level hook's return value is what - * suppresses a key from reaching the OS), Linux already grabbed the physical - * device exclusively (EVIOCGRAB): nothing reaches other apps unless we write it - * to the uinput device ourselves. So the regurgitates KeyboardManager computes - * (e.g. a precondition key like leftbutton typed back out because the chord it - * was gating never completed) must be explicitly forwarded here, or they are - * silently dropped. + * Unlike Windows' low-level hook return value, Linux has no way to suppress a key + * after grabbing the device exclusively (EVIOCGRAB) — regurgitates must be + * explicitly re-injected here or they're silently dropped. */ private void handleKeyEvent(KeyEvent event) { KeyboardManager.EatAndRegurgitates eatAndRegurgitates = keyboardManager.keyEvent(event); @@ -144,10 +140,9 @@ public void sleep() throws InterruptedException { } /** - * Windows notifies MousePositionListener (e.g. MouseManager, used to detect when a - * smooth jump has reached its destination and clear the atomic-command-in-progress - * flag) via its low-level mouse hook, which sees every cursor move including - * synthetic ones. X11 has no such hook, so the position is polled instead. + * Windows notifies MousePositionListener (e.g. MouseManager, to detect a smooth + * jump reaching its destination) via its low-level mouse hook. X11 has no such + * hook, so the position is polled instead. */ private void notifyMousePositionListenersIfMoved() { LongByReference root = new LongByReference(); @@ -163,7 +158,9 @@ private void notifyMousePositionListenersIfMoved() { return; int x = rootX.getValue(); int y = rootY.getValue(); - if (lastMouseX != null && lastMouseX == x && lastMouseY != null && lastMouseY == y) + boolean positionUnchanged = lastMouseX != null && lastMouseX == x && + lastMouseY != null && lastMouseY == y; + if (positionUnchanged) return; lastMouseX = x; lastMouseY = y; From 61edac06c1d89212ce8bab61b12b6581d2f9162c Mon Sep 17 00:00:00 2001 From: SudoWatson Date: Fri, 17 Jul 2026 16:27:18 -0400 Subject: [PATCH 11/23] 5 minute failsafe --- src/main/java/mousemaster/platform/linux/LinuxMain.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/mousemaster/platform/linux/LinuxMain.java b/src/main/java/mousemaster/platform/linux/LinuxMain.java index 24bb7734..34694502 100644 --- a/src/main/java/mousemaster/platform/linux/LinuxMain.java +++ b/src/main/java/mousemaster/platform/linux/LinuxMain.java @@ -50,11 +50,11 @@ public static void main(String[] args) throws InterruptedException, IOException } Thread failsafe = new Thread(() -> { try { - Thread.sleep(60_000); + Thread.sleep(300_000); } catch (InterruptedException ignored) { return; } - logger.warn("60-second failsafe triggered — forcing exit to release keyboard grab"); + logger.warn("5-minute failsafe triggered — forcing exit to release keyboard grab"); System.exit(0); }, "failsafe-shutdown"); failsafe.setDaemon(true); From 764fefea3fda1707259dbc2af77485342e475367 Mon Sep 17 00:00:00 2001 From: SudoWatson Date: Sat, 18 Jul 2026 18:45:12 -0400 Subject: [PATCH 12/23] Don't detect mac --- pom.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pom.xml b/pom.xml index a5e7475b..66f3b630 100644 --- a/pom.xml +++ b/pom.xml @@ -91,7 +91,7 @@ linux - unix + Linux mousemaster.platform.linux.LinuxMain From 521a77353bdc001dd1823e688542acbd67e272f0 Mon Sep 17 00:00:00 2001 From: SudoWatson Date: Sat, 18 Jul 2026 20:53:10 -0400 Subject: [PATCH 13/23] Wayland works now --- flake.nix | 3 + .../mousemaster/platform/MouseController.java | 3 + .../platform/linux/LibWaylandClient.java | 150 ++++++++ .../platform/linux/LibWlrVirtualPointer.java | 124 +++++++ .../platform/linux/LinuxMouse.java | 140 +------- .../platform/linux/LinuxPlatform.java | 42 ++- .../platform/linux/WaylandMouse.java | 329 ++++++++++++++++++ .../mousemaster/platform/linux/X11Mouse.java | 138 ++++++++ 8 files changed, 785 insertions(+), 144 deletions(-) create mode 100644 src/main/java/mousemaster/platform/linux/LibWaylandClient.java create mode 100644 src/main/java/mousemaster/platform/linux/LibWlrVirtualPointer.java create mode 100644 src/main/java/mousemaster/platform/linux/WaylandMouse.java create mode 100644 src/main/java/mousemaster/platform/linux/X11Mouse.java diff --git a/flake.nix b/flake.nix index 1b218770..1a1f010f 100644 --- a/flake.nix +++ b/flake.nix @@ -44,6 +44,8 @@ fontconfig freetype mesa # provides libGL / libEGL + + wayland # libwayland-client.so, for the Wayland virtual-pointer JNA bindings ]; shellHook = '' @@ -66,6 +68,7 @@ fontconfig freetype mesa + wayland ])}:$LD_LIBRARY_PATH" export QT_QPA_PLATFORM_PLUGIN_PATH="${pkgs68.qt6.qtbase}/lib/qt-6/plugins/platforms" echo "Mousemaster dev environment ready" diff --git a/src/main/java/mousemaster/platform/MouseController.java b/src/main/java/mousemaster/platform/MouseController.java index 8a5fc977..09d7e60e 100644 --- a/src/main/java/mousemaster/platform/MouseController.java +++ b/src/main/java/mousemaster/platform/MouseController.java @@ -29,4 +29,7 @@ public interface MouseController { void showCursor(); void hideCursor(); + + default void destroy() { + } } diff --git a/src/main/java/mousemaster/platform/linux/LibWaylandClient.java b/src/main/java/mousemaster/platform/linux/LibWaylandClient.java new file mode 100644 index 00000000..8434774f --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LibWaylandClient.java @@ -0,0 +1,150 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Callback; +import com.sun.jna.Library; +import com.sun.jna.Memory; +import com.sun.jna.Native; +import com.sun.jna.NativeLibrary; +import com.sun.jna.Pointer; +import com.sun.jna.Structure; + +import java.nio.charset.StandardCharsets; +import java.util.ArrayList; +import java.util.List; + +/** + * Generic, protocol-agnostic JNA bindings for libwayland-client.so: connection + * lifecycle and the wire-level proxy marshalling API. Protocol-specific requests + * (e.g. zwlr_virtual_pointer_v1) are built on top of this in LibWlrVirtualPointer. + */ +public interface LibWaylandClient extends Library { + + LibWaylandClient INSTANCE = Native.load("wayland-client", LibWaylandClient.class); + + int WL_MARSHAL_FLAG_DESTROY = 1; + + // Core wl_display/wl_registry opcodes (wayland.xml), not specific to any extension. + int WL_DISPLAY_GET_REGISTRY = 1; // signature "n" + int WL_REGISTRY_BIND = 0; // signature "usun" (special-cased dynamic bind) + + Pointer wl_display_connect(String name); + void wl_display_disconnect(Pointer display); + int wl_display_dispatch(Pointer display); + int wl_display_roundtrip(Pointer display); + int wl_display_flush(Pointer display); + + Pointer wl_proxy_marshal_array_flags(Pointer proxy, int opcode, Pointer interface_, + int version, int flags, Pointer args); + int wl_proxy_add_listener(Pointer proxy, Pointer implementation, Pointer data); + void wl_proxy_destroy(Pointer proxy); + int wl_proxy_get_version(Pointer proxy); + + @Structure.FieldOrder({"name", "signature", "types"}) + class WlMessage extends Structure { + public String name; + public String signature; + public Pointer types; + + public WlMessage() { + super(); + } + + public WlMessage(Pointer p) { + super(p); + } + } + + @Structure.FieldOrder({"name", "version", "method_count", "methods", "event_count", "events"}) + class WlInterface extends Structure { + public String name; + public int version; + public int method_count; + public Pointer methods; + public int event_count; + public Pointer events; + } + + interface WlRegistryGlobalCallback extends Callback { + void invoke(Pointer data, Pointer registry, int name, String interfaceName, int version); + } + + interface WlRegistryGlobalRemoveCallback extends Callback { + void invoke(Pointer data, Pointer registry, int name); + } + + @Structure.FieldOrder({"global", "globalRemove"}) + class WlRegistryListener extends Structure { + public WlRegistryGlobalCallback global; + public WlRegistryGlobalRemoveCallback globalRemove; + } + + /** + * Raw union wl_argument[] builder (8-byte slots on x86_64, since the union's + * widest members are pointers). Every slot in a message's signature needs an + * entry here, including the new_id slot native code fills in automatically. + */ + class WlArgs { + private static final int SLOT_SIZE = 8; + private final Memory mem; + private final List stringBacking = new ArrayList<>(); + + public WlArgs(int slotCount) { + mem = new Memory((long) SLOT_SIZE * Math.max(slotCount, 1)); + mem.clear(); + } + + public WlArgs setUint(int slot, int value) { + mem.setInt((long) slot * SLOT_SIZE, value); + return this; + } + + public WlArgs setInt(int slot, int value) { + mem.setInt((long) slot * SLOT_SIZE, value); + return this; + } + + public WlArgs setFixed(int slot, int fixedValue) { + mem.setInt((long) slot * SLOT_SIZE, fixedValue); + return this; + } + + public WlArgs setString(int slot, String value) { + byte[] bytes = value.getBytes(StandardCharsets.UTF_8); + Memory str = new Memory(bytes.length + 1L); + str.write(0, bytes, 0, bytes.length); + str.setByte(bytes.length, (byte) 0); + stringBacking.add(str); + mem.setPointer((long) slot * SLOT_SIZE, str); + return this; + } + + public WlArgs setObject(int slot, Pointer value) { + mem.setPointer((long) slot * SLOT_SIZE, value); + return this; + } + + public Pointer pointer() { + return mem; + } + } + + static int fixedFromInt(int value) { + return value * 256; + } + + static int fixedFromDouble(double value) { + return (int) Math.round(value * 256.0); + } + + static int fixedToInt(int fixedValue) { + return fixedValue / 256; + } + + static double fixedToDouble(int fixedValue) { + return fixedValue / 256.0; + } + + static Pointer globalInterfacePointer(String symbolName) { + return NativeLibrary.getInstance("wayland-client").getGlobalVariableAddress(symbolName); + } +} diff --git a/src/main/java/mousemaster/platform/linux/LibWlrVirtualPointer.java b/src/main/java/mousemaster/platform/linux/LibWlrVirtualPointer.java new file mode 100644 index 00000000..cd40a046 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/LibWlrVirtualPointer.java @@ -0,0 +1,124 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Memory; +import com.sun.jna.Pointer; + +/** + * Hand-rolled wl_interface/wl_message data for zwlr_virtual_pointer_manager_v1 and + * zwlr_virtual_pointer_v1 (wlr-protocols, version 2 of each). Neither interface has + * any events, so the "types" arrays below are populated for correctness/parity with + * real wayland-scanner output but are never actually read by outbound marshalling. + */ +final class LibWlrVirtualPointer { + + static final int VP_OP_MOTION = 0; // "uff" + static final int VP_OP_MOTION_ABSOLUTE = 1; // "uuuuu" + static final int VP_OP_BUTTON = 2; // "uuu" (unused, see WaylandMouse) + static final int VP_OP_AXIS = 3; // "uuf" (unused) + static final int VP_OP_FRAME = 4; // "" + static final int VP_OP_AXIS_SOURCE = 5; // "u" (unused) + static final int VP_OP_AXIS_STOP = 6; // "uu" (unused) + static final int VP_OP_AXIS_DISCRETE = 7; // "uufi" (unused) + static final int VP_OP_DESTROY = 8; // "" + + static final int MGR_OP_CREATE_VIRTUAL_POINTER = 0; // "?on" + static final int MGR_OP_DESTROY = 1; // "" + static final int MGR_OP_CREATE_VIRTUAL_POINTER_WITH_OUTPUT = 2; // "?o?on" (unused) + + static final LibWaylandClient.WlInterface ZWLR_VIRTUAL_POINTER_V1_INTERFACE = + buildVirtualPointerV1Interface(); + static final LibWaylandClient.WlInterface ZWLR_VIRTUAL_POINTER_MANAGER_V1_INTERFACE = + buildManagerInterface(); + + // Must stay reachable for the process lifetime: iface.methods only retains the raw + // native pointer to element 0, not the individual WlMessage Java objects, which are + // what keep their name/signature string-backing Memory alive. Without these fields, + // methods[1..] get GC'd once the builders below return, their string memory gets + // freed and later reused by unrelated allocations, and native code ends up reading + // corrupted method names/signatures out of the (still-contiguous) methods array. + private static LibWaylandClient.WlMessage[] virtualPointerV1Methods; + private static LibWaylandClient.WlMessage[] managerMethods; + + private LibWlrVirtualPointer() { + } + + private static LibWaylandClient.WlMessage[] buildMessages(int count) { + LibWaylandClient.WlMessage first = new LibWaylandClient.WlMessage(); + return (LibWaylandClient.WlMessage[]) first.toArray(count); + } + + private static Pointer typesArray(Pointer... interfacePointers) { + Memory mem = new Memory(8L * interfacePointers.length); + for (int i = 0; i < interfacePointers.length; i++) + mem.setPointer(8L * i, interfacePointers[i]); + return mem; + } + + private static LibWaylandClient.WlInterface buildVirtualPointerV1Interface() { + LibWaylandClient.WlMessage[] methods = buildMessages(9); + methods[VP_OP_MOTION].name = "motion"; + methods[VP_OP_MOTION].signature = "uff"; + methods[VP_OP_MOTION_ABSOLUTE].name = "motion_absolute"; + methods[VP_OP_MOTION_ABSOLUTE].signature = "uuuuu"; + methods[VP_OP_BUTTON].name = "button"; + methods[VP_OP_BUTTON].signature = "uuu"; + methods[VP_OP_AXIS].name = "axis"; + methods[VP_OP_AXIS].signature = "uuf"; + methods[VP_OP_FRAME].name = "frame"; + methods[VP_OP_FRAME].signature = ""; + methods[VP_OP_AXIS_SOURCE].name = "axis_source"; + methods[VP_OP_AXIS_SOURCE].signature = "u"; + methods[VP_OP_AXIS_STOP].name = "axis_stop"; + methods[VP_OP_AXIS_STOP].signature = "uu"; + methods[VP_OP_AXIS_DISCRETE].name = "axis_discrete"; + methods[VP_OP_AXIS_DISCRETE].signature = "uufi"; + methods[VP_OP_DESTROY].name = "destroy"; + methods[VP_OP_DESTROY].signature = ""; + for (LibWaylandClient.WlMessage m : methods) { + m.types = Pointer.NULL; + m.write(); + } + + LibWaylandClient.WlInterface iface = new LibWaylandClient.WlInterface(); + iface.name = "zwlr_virtual_pointer_v1"; + iface.version = 2; + iface.method_count = methods.length; + iface.methods = methods[0].getPointer(); + iface.event_count = 0; + iface.events = Pointer.NULL; + iface.write(); + virtualPointerV1Methods = methods; + return iface; + } + + private static LibWaylandClient.WlInterface buildManagerInterface() { + Pointer wlSeatInterface = LibWaylandClient.globalInterfacePointer("wl_seat_interface"); + Pointer wlOutputInterface = LibWaylandClient.globalInterfacePointer("wl_output_interface"); + Pointer vp1Interface = ZWLR_VIRTUAL_POINTER_V1_INTERFACE.getPointer(); + + LibWaylandClient.WlMessage[] methods = buildMessages(3); + methods[MGR_OP_CREATE_VIRTUAL_POINTER].name = "create_virtual_pointer"; + methods[MGR_OP_CREATE_VIRTUAL_POINTER].signature = "?on"; + methods[MGR_OP_CREATE_VIRTUAL_POINTER].types = typesArray(wlSeatInterface, vp1Interface); + methods[MGR_OP_DESTROY].name = "destroy"; + methods[MGR_OP_DESTROY].signature = ""; + methods[MGR_OP_DESTROY].types = Pointer.NULL; + methods[MGR_OP_CREATE_VIRTUAL_POINTER_WITH_OUTPUT].name = "create_virtual_pointer_with_output"; + methods[MGR_OP_CREATE_VIRTUAL_POINTER_WITH_OUTPUT].signature = "?o?on"; + methods[MGR_OP_CREATE_VIRTUAL_POINTER_WITH_OUTPUT].types = + typesArray(wlSeatInterface, wlOutputInterface, vp1Interface); + for (LibWaylandClient.WlMessage m : methods) + m.write(); + + LibWaylandClient.WlInterface iface = new LibWaylandClient.WlInterface(); + iface.name = "zwlr_virtual_pointer_manager_v1"; + iface.version = 2; + iface.method_count = methods.length; + iface.methods = methods[0].getPointer(); + iface.event_count = 0; + iface.events = Pointer.NULL; + iface.write(); + managerMethods = methods; + return iface; + } +} diff --git a/src/main/java/mousemaster/platform/linux/LinuxMouse.java b/src/main/java/mousemaster/platform/linux/LinuxMouse.java index 920b4c6f..6a20a84c 100644 --- a/src/main/java/mousemaster/platform/linux/LinuxMouse.java +++ b/src/main/java/mousemaster/platform/linux/LinuxMouse.java @@ -1,138 +1,20 @@ package mousemaster.platform.linux; -import com.sun.jna.Pointer; import mousemaster.platform.MouseController; -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -// X11-only implementation. Wayland cursor movement requires zwlr_virtual_pointer_v1 -// or a working uinput REL device — deferred to a later milestone. -public class LinuxMouse implements MouseController { +// Common supertype for X11Mouse and WaylandMouse, so LinuxPlatform's mouse field +// can only ever be one of this platform's own MouseController implementations. +public abstract class LinuxMouse implements MouseController { - private static final Logger logger = LoggerFactory.getLogger(LinuxMouse.class); - - // X11 button numbers - private static final int BTN_LEFT = 1; - private static final int BTN_MIDDLE = 2; - private static final int BTN_RIGHT = 3; - // Vertical scroll: 4 = up, 5 = down - // Horizontal scroll: 6 = left, 7 = right - - private final Pointer display; - private final long rootWindow; - private long hiddenCursor = 0; - - public LinuxMouse(Pointer display, long rootWindow) { - this.display = display; - this.rootWindow = rootWindow; - } - - public void destroy() { - if (hiddenCursor != 0) { - LibX11.INSTANCE.XFreeCursor(display, hiddenCursor); - hiddenCursor = 0; - } - } - - @Override - public void beginMove() { - } - - @Override - public void endMove() { - LibX11.INSTANCE.XFlush(display); - } - - @Override - public void moveBy(boolean xForward, double dx, boolean yForward, double dy) { - int ix = (int) dx * (xForward ? 1 : -1); - int iy = (int) dy * (yForward ? 1 : -1); - if (ix == 0 && iy == 0) return; - // dest_window = None (0) → coords are relative to current pointer position - LibX11.INSTANCE.XWarpPointer(display, 0, 0, 0, 0, 0, 0, ix, iy); - } - - @Override - public void synchronousMoveTo(int x, int y) { - LibX11.INSTANCE.XWarpPointer(display, 0, rootWindow, 0, 0, 0, 0, x, y); - LibX11.INSTANCE.XFlush(display); - } - - @Override - public void pressLeft() { - buttonEvent(BTN_LEFT, true); - } - - @Override - public void releaseLeft() { - buttonEvent(BTN_LEFT, false); - } - - @Override - public void pressMiddle() { - buttonEvent(BTN_MIDDLE, true); - } - - @Override - public void releaseMiddle() { - buttonEvent(BTN_MIDDLE, false); - } - - @Override - public void pressRight() { - buttonEvent(BTN_RIGHT, true); - } - - @Override - public void releaseRight() { - buttonEvent(BTN_RIGHT, false); - } - - @Override - public void wheelVerticallyBy(boolean forward, double delta) { - // forward = away from user = scroll up = button 4 - int button = forward ? 4 : 5; - int count = Math.max(1, (int) delta); - for (int i = 0; i < count; i++) { - buttonEvent(button, true); - buttonEvent(button, false); - } - LibX11.INSTANCE.XFlush(display); - } - - @Override - public void wheelHorizontallyBy(boolean forward, double delta) { - // forward = right = button 7 - int button = forward ? 7 : 6; - int count = Math.max(1, (int) delta); - for (int i = 0; i < count; i++) { - buttonEvent(button, true); - buttonEvent(button, false); - } - LibX11.INSTANCE.XFlush(display); - } - - @Override - public void hideCursor() { - if (hiddenCursor == 0) { - byte[] blankData = {0}; - long pixmap = LibX11.INSTANCE.XCreateBitmapFromData(display, rootWindow, blankData, 1, 1); - LibX11.XColor black = new LibX11.XColor(); - hiddenCursor = LibX11.INSTANCE.XCreatePixmapCursor(display, pixmap, pixmap, black, black, 0, 0); - LibX11.INSTANCE.XFreePixmap(display, pixmap); - } - LibX11.INSTANCE.XDefineCursor(display, rootWindow, hiddenCursor); - LibX11.INSTANCE.XFlush(display); - } - - @Override - public void showCursor() { - LibX11.INSTANCE.XUndefineCursor(display, rootWindow); - LibX11.INSTANCE.XFlush(display); + // Overridden by WaylandMouse: XWayland's XQueryPointer-mirrored pointer state doesn't + // reliably reflect motion injected via zwlr_virtual_pointer_v1, so LinuxPlatform's + // position-listener polling needs the last position we ourselves told the compositor + // to move to, instead of (or in preference to) an XQueryPointer round-trip. + public Integer lastSyntheticX() { + return null; } - private void buttonEvent(int button, boolean press) { - LibXTest.INSTANCE.XTestFakeButtonEvent(display, button, press ? 1 : 0, 0); - LibX11.INSTANCE.XFlush(display); + public Integer lastSyntheticY() { + return null; } } diff --git a/src/main/java/mousemaster/platform/linux/LinuxPlatform.java b/src/main/java/mousemaster/platform/linux/LinuxPlatform.java index 82012171..7073a954 100644 --- a/src/main/java/mousemaster/platform/linux/LinuxPlatform.java +++ b/src/main/java/mousemaster/platform/linux/LinuxPlatform.java @@ -75,11 +75,11 @@ public LinuxPlatform(boolean multipleInstancesAllowed, boolean keyRegurgitationE console = new LinuxConsole(); keyRegurgitator = new KeyRegurgitator(keyboard); - // rootWindow must be obtained before creating LinuxMouse (mouse needs it for XWarpPointer) + // rootWindow must be obtained before creating X11Mouse (mouse needs it for XWarpPointer) rootWindow = LibX11.INSTANCE.XDefaultRootWindow(display); logger.info("Root window handle: {}", rootWindow); - mouse = new LinuxMouse(display, rootWindow); + mouse = isWayland ? new WaylandMouse(screens) : new X11Mouse(display, rootWindow); evdev = new LinuxEvdev(); logger.info("LinuxPlatform initialized successfully"); @@ -145,19 +145,31 @@ public void sleep() throws InterruptedException { * hook, so the position is polled instead. */ private void notifyMousePositionListenersIfMoved() { - LongByReference root = new LongByReference(); - LongByReference child = new LongByReference(); - IntByReference rootX = new IntByReference(); - IntByReference rootY = new IntByReference(); - IntByReference winX = new IntByReference(); - IntByReference winY = new IntByReference(); - IntByReference mask = new IntByReference(); - boolean sameScreen = LibX11.INSTANCE.XQueryPointer(display, rootWindow, root, child, - rootX, rootY, winX, winY, mask); - if (!sameScreen) - return; - int x = rootX.getValue(); - int y = rootY.getValue(); + int x; + int y; + // XWayland's XQueryPointer-mirrored pointer state doesn't reliably reflect motion + // injected via zwlr_virtual_pointer_v1 (WaylandMouse), so prefer the position we + // ourselves last told the compositor to move to over an XQueryPointer round-trip. + Integer syntheticX = mouse.lastSyntheticX(); + Integer syntheticY = mouse.lastSyntheticY(); + if (syntheticX != null && syntheticY != null) { + x = syntheticX; + y = syntheticY; + } else { + LongByReference root = new LongByReference(); + LongByReference child = new LongByReference(); + IntByReference rootX = new IntByReference(); + IntByReference rootY = new IntByReference(); + IntByReference winX = new IntByReference(); + IntByReference winY = new IntByReference(); + IntByReference mask = new IntByReference(); + boolean sameScreen = LibX11.INSTANCE.XQueryPointer(display, rootWindow, root, child, + rootX, rootY, winX, winY, mask); + if (!sameScreen) + return; + x = rootX.getValue(); + y = rootY.getValue(); + } boolean positionUnchanged = lastMouseX != null && lastMouseX == x && lastMouseY != null && lastMouseY == y; if (positionUnchanged) diff --git a/src/main/java/mousemaster/platform/linux/WaylandMouse.java b/src/main/java/mousemaster/platform/linux/WaylandMouse.java new file mode 100644 index 00000000..4c16d407 --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/WaylandMouse.java @@ -0,0 +1,329 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Pointer; +import mousemaster.Rectangle; +import mousemaster.Screen; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import java.util.Set; + +/** + * Wayland implementation of MouseController, using the wlr-protocols + * zwlr_virtual_pointer_v1 extension for cursor motion. Only supported on + * wlroots-based compositors (Hyprland, Sway) that advertise + * zwlr_virtual_pointer_manager_v1 - see markUnavailable() for the fallback + * behavior on compositors that don't (GNOME, KDE Plasma). + */ +public class WaylandMouse extends LinuxMouse { + + private static final Logger logger = LoggerFactory.getLogger(WaylandMouse.class); + + private final LinuxScreens screens; + private final int uinputMouseFd; + private final LibWaylandClient.WlRegistryListener registryListener; + + private Pointer display; + private Pointer registry; + private Pointer seatProxy; + private Pointer managerProxy; + private Pointer virtualPointerProxy; + private boolean protocolAvailable; + + private boolean managerFound; + private int managerName; + private int managerVersion; + private boolean seatFound; + private int seatName; + private int seatVersion; + + private volatile Integer lastX; + private volatile Integer lastY; + + @Override + public Integer lastSyntheticX() { + return lastX; + } + + @Override + public Integer lastSyntheticY() { + return lastY; + } + + public WaylandMouse(LinuxScreens screens) { + this.screens = screens; + this.uinputMouseFd = LibUinput.createMouseDevice(); + this.registryListener = new LibWaylandClient.WlRegistryListener(); + registryListener.global = this::onGlobal; + registryListener.globalRemove = this::onGlobalRemove; + try { + connectAndBootstrap(); + } catch (Throwable e) { + // Catches Throwable, not just Exception: a failure to load libwayland-client.so + // surfaces as UnsatisfiedLinkError/NoClassDefFoundError, which are Errors, not + // Exceptions - without this, that would crash the whole app instead of just + // disabling cursor movement. + logger.error("Failed to initialize Wayland virtual pointer, cursor movement disabled", e); + protocolAvailable = false; + } + } + + private void connectAndBootstrap() { + display = LibWaylandClient.INSTANCE.wl_display_connect(null); + if (display == null) { + markUnavailable("wl_display_connect failed (no $WAYLAND_DISPLAY socket)"); + return; + } + + LibWaylandClient.WlArgs getRegistryArgs = new LibWaylandClient.WlArgs(1); + registry = LibWaylandClient.INSTANCE.wl_proxy_marshal_array_flags(display, + LibWaylandClient.WL_DISPLAY_GET_REGISTRY, + LibWaylandClient.globalInterfacePointer("wl_registry_interface"), + LibWaylandClient.INSTANCE.wl_proxy_get_version(display), 0, getRegistryArgs.pointer()); + + registryListener.write(); + LibWaylandClient.INSTANCE.wl_proxy_add_listener(registry, registryListener.getPointer(), null); + LibWaylandClient.INSTANCE.wl_display_roundtrip(display); // collects the initial batch of `global` events + + if (!managerFound) { + markUnavailable("compositor did not advertise zwlr_virtual_pointer_manager_v1"); + return; + } + managerProxy = bind(managerName, managerVersion, + LibWlrVirtualPointer.ZWLR_VIRTUAL_POINTER_MANAGER_V1_INTERFACE.getPointer(), + "zwlr_virtual_pointer_manager_v1"); + if (seatFound) + seatProxy = bind(seatName, seatVersion, + LibWaylandClient.globalInterfacePointer("wl_seat_interface"), "wl_seat"); + + virtualPointerProxy = createVirtualPointer(); + LibWaylandClient.INSTANCE.wl_display_flush(display); + protocolAvailable = virtualPointerProxy != null; + logger.info("Wayland virtual pointer bootstrapped (manager v{}, seat {})", + managerVersion, seatProxy != null ? "bound" : "none"); + } + + private void onGlobal(Pointer data, Pointer registryProxy, int name, String interfaceName, int version) { + if ("zwlr_virtual_pointer_manager_v1".equals(interfaceName)) { + managerName = name; + managerVersion = Math.min(version, 2); + managerFound = true; + } else if ("wl_seat".equals(interfaceName) && !seatFound) { + seatName = name; + seatVersion = version; + seatFound = true; + } + } + + private void onGlobalRemove(Pointer data, Pointer registryProxy, int name) { + } + + private Pointer bind(int name, int version, Pointer interfacePointer, String interfaceName) { + LibWaylandClient.WlArgs args = new LibWaylandClient.WlArgs(4); + args.setUint(0, name); + args.setString(1, interfaceName); + args.setUint(2, version); + return LibWaylandClient.INSTANCE.wl_proxy_marshal_array_flags(registry, + LibWaylandClient.WL_REGISTRY_BIND, interfacePointer, version, 0, args.pointer()); + } + + private Pointer createVirtualPointer() { + LibWaylandClient.WlArgs args = new LibWaylandClient.WlArgs(2); + if (seatProxy != null) + args.setObject(0, seatProxy); + return LibWaylandClient.INSTANCE.wl_proxy_marshal_array_flags(managerProxy, + LibWlrVirtualPointer.MGR_OP_CREATE_VIRTUAL_POINTER, + LibWlrVirtualPointer.ZWLR_VIRTUAL_POINTER_V1_INTERFACE.getPointer(), + managerVersion, 0, args.pointer()); + } + + private void markUnavailable(String reason) { + protocolAvailable = false; + String desktop = System.getenv("XDG_CURRENT_DESKTOP"); + logger.warn("Wayland cursor movement unavailable: {}. zwlr_virtual_pointer_v1 is supported by " + + "wlroots-based compositors (Hyprland, Sway) but not GNOME or KDE Plasma. Cursor movement " + + "will be disabled for this session; keyboard capture and hint navigation are unaffected.{}", + reason, desktop != null ? " Detected desktop: " + desktop + "." : ""); + } + + private boolean ensureAvailable() { + return protocolAvailable; + } + + // ---- MouseController: cursor motion, via zwlr_virtual_pointer_v1 ---- + + @Override + public void beginMove() { + } + + @Override + public void endMove() { + if (protocolAvailable) + LibWaylandClient.INSTANCE.wl_display_flush(display); + } + + @Override + public void moveBy(boolean xForward, double dx, boolean yForward, double dy) { + if (!ensureAvailable() || (dx == 0 && dy == 0)) + return; + double signedDx = xForward ? dx : -dx; + double signedDy = yForward ? dy : -dy; + LibWaylandClient.WlArgs args = new LibWaylandClient.WlArgs(3); + args.setUint(0, (int) System.currentTimeMillis()); + args.setFixed(1, LibWaylandClient.fixedFromDouble(signedDx)); + args.setFixed(2, LibWaylandClient.fixedFromDouble(signedDy)); + marshalPointerRequest(LibWlrVirtualPointer.VP_OP_MOTION, args.pointer()); + frame(); + LibWaylandClient.INSTANCE.wl_display_flush(display); + if (lastX != null && lastY != null) { + lastX += (int) Math.round(signedDx); + lastY += (int) Math.round(signedDy); + } + } + + @Override + public void synchronousMoveTo(int x, int y) { + if (!ensureAvailable()) + return; + Rectangle bounds = screenBounds(); + LibWaylandClient.WlArgs args = new LibWaylandClient.WlArgs(5); + args.setUint(0, (int) System.currentTimeMillis()); + args.setUint(1, x - bounds.x()); + args.setUint(2, y - bounds.y()); + args.setUint(3, bounds.width()); + args.setUint(4, bounds.height()); + marshalPointerRequest(LibWlrVirtualPointer.VP_OP_MOTION_ABSOLUTE, args.pointer()); + frame(); + LibWaylandClient.INSTANCE.wl_display_flush(display); + lastX = x; + lastY = y; + } + + // Uncached on purpose, matching how ScreenManager/HintManager/GridManager already + // call Screens.findScreens() uncached at similar frequency elsewhere in the app. + private Rectangle screenBounds() { + Set allScreens = screens.findScreens(); + int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE; + int maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE; + for (Screen screen : allScreens) { + Rectangle r = screen.rectangle(); + minX = Math.min(minX, r.x()); + minY = Math.min(minY, r.y()); + maxX = Math.max(maxX, r.x() + r.width()); + maxY = Math.max(maxY, r.y() + r.height()); + } + return new Rectangle(minX, minY, maxX - minX, maxY - minY); + } + + private void marshalPointerRequest(int opcode, Pointer argsPointer) { + LibWaylandClient.INSTANCE.wl_proxy_marshal_array_flags(virtualPointerProxy, opcode, Pointer.NULL, + LibWaylandClient.INSTANCE.wl_proxy_get_version(virtualPointerProxy), 0, argsPointer); + } + + private void frame() { + marshalPointerRequest(LibWlrVirtualPointer.VP_OP_FRAME, Pointer.NULL); + } + + // ---- MouseController: buttons/wheel ---- + // + // Route A (chosen over adding button/axis/frame calls to the Wayland protocol above): + // clicks and scroll go through the kernel uinput mouse device (LibUinput.createMouseDevice(), + // already built and unused elsewhere) instead of zwlr_virtual_pointer_v1's own button/axis + // opcodes. That device's own docstring already describes it as working on "both X11 and + // native Wayland", and reusing it is far less new/risky code than hand-marshalling three more + // wire opcodes. This mirrors how keyboard input already works on Linux (evdev capture + uinput + // injection, independent of display server) - the deliberate trade-off is that WaylandMouse + // ends up using two transports (Wayland wire protocol for motion, kernel uinput for clicks and + // wheel), both of which are ultimately resolved by the same compositor. + + @Override + public void pressLeft() { + uinputButton(LibUinput.BTN_LEFT, 1); + } + + @Override + public void releaseLeft() { + uinputButton(LibUinput.BTN_LEFT, 0); + } + + @Override + public void pressMiddle() { + uinputButton(LibUinput.BTN_MIDDLE, 1); + } + + @Override + public void releaseMiddle() { + uinputButton(LibUinput.BTN_MIDDLE, 0); + } + + @Override + public void pressRight() { + uinputButton(LibUinput.BTN_RIGHT, 1); + } + + @Override + public void releaseRight() { + uinputButton(LibUinput.BTN_RIGHT, 0); + } + + @Override + public void wheelVerticallyBy(boolean forward, double delta) { + uinputWheel(LibUinput.REL_WHEEL, forward ? 1 : -1, delta); + } + + @Override + public void wheelHorizontallyBy(boolean forward, double delta) { + uinputWheel(LibUinput.REL_HWHEEL, forward ? 1 : -1, delta); + } + + private void uinputButton(int code, int value) { + LibUinput.writeInputEvent(uinputMouseFd, LibUinput.EV_KEY, code, value); + LibUinput.writeInputEvent(uinputMouseFd, LibUinput.EV_SYN, LibUinput.SYN_REPORT, 0); + } + + private void uinputWheel(int axisCode, int sign, double delta) { + int count = Math.max(1, (int) delta); + for (int i = 0; i < count; i++) + LibUinput.writeInputEvent(uinputMouseFd, LibUinput.EV_REL, axisCode, sign); + LibUinput.writeInputEvent(uinputMouseFd, LibUinput.EV_SYN, LibUinput.SYN_REPORT, 0); + } + + // ---- MouseController: cursor visibility ---- + // + // Permanent scope boundary, not a bug to fix later: zwlr_virtual_pointer_v1 has no + // cursor-image request. Wayland cursor sprites are normally controlled via + // wl_pointer.set_cursor, which requires holding pointer focus over one of the client's + // own surfaces - a pure input-injection virtual pointer has no surface and no pointer + // focus of its own, so there is no way to hide/change the system cursor from here. + + @Override + public void showCursor() { + logger.debug("showCursor() is a no-op under Wayland - see WaylandMouse class comment"); + } + + @Override + public void hideCursor() { + logger.debug("hideCursor() is a no-op under Wayland - see WaylandMouse class comment"); + } + + @Override + public void destroy() { + if (virtualPointerProxy != null) + destroyRemoteObject(virtualPointerProxy, LibWlrVirtualPointer.VP_OP_DESTROY); + if (managerProxy != null) + destroyRemoteObject(managerProxy, LibWlrVirtualPointer.MGR_OP_DESTROY); + if (seatProxy != null) + LibWaylandClient.INSTANCE.wl_proxy_destroy(seatProxy); + if (registry != null) + LibWaylandClient.INSTANCE.wl_proxy_destroy(registry); + if (display != null) + LibWaylandClient.INSTANCE.wl_display_disconnect(display); + LibUinput.destroyDevice(uinputMouseFd); + } + + private void destroyRemoteObject(Pointer proxy, int destroyOpcode) { + LibWaylandClient.INSTANCE.wl_proxy_marshal_array_flags(proxy, destroyOpcode, Pointer.NULL, + LibWaylandClient.INSTANCE.wl_proxy_get_version(proxy), + LibWaylandClient.WL_MARSHAL_FLAG_DESTROY, Pointer.NULL); + } +} diff --git a/src/main/java/mousemaster/platform/linux/X11Mouse.java b/src/main/java/mousemaster/platform/linux/X11Mouse.java new file mode 100644 index 00000000..b6f27c9a --- /dev/null +++ b/src/main/java/mousemaster/platform/linux/X11Mouse.java @@ -0,0 +1,138 @@ +package mousemaster.platform.linux; + +import com.sun.jna.Pointer; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +// X11-only implementation. See WaylandMouse for the zwlr_virtual_pointer_v1-based +// implementation used under Wayland. +public class X11Mouse extends LinuxMouse { + + private static final Logger logger = LoggerFactory.getLogger(X11Mouse.class); + + // X11 button numbers + private static final int BTN_LEFT = 1; + private static final int BTN_MIDDLE = 2; + private static final int BTN_RIGHT = 3; + // Vertical scroll: 4 = up, 5 = down + // Horizontal scroll: 6 = left, 7 = right + + private final Pointer display; + private final long rootWindow; + private long hiddenCursor = 0; + + public X11Mouse(Pointer display, long rootWindow) { + this.display = display; + this.rootWindow = rootWindow; + } + + @Override + public void destroy() { + if (hiddenCursor != 0) { + LibX11.INSTANCE.XFreeCursor(display, hiddenCursor); + hiddenCursor = 0; + } + } + + @Override + public void beginMove() { + } + + @Override + public void endMove() { + LibX11.INSTANCE.XFlush(display); + } + + @Override + public void moveBy(boolean xForward, double dx, boolean yForward, double dy) { + int ix = (int) dx * (xForward ? 1 : -1); + int iy = (int) dy * (yForward ? 1 : -1); + if (ix == 0 && iy == 0) return; + // dest_window = None (0) → coords are relative to current pointer position + LibX11.INSTANCE.XWarpPointer(display, 0, 0, 0, 0, 0, 0, ix, iy); + } + + @Override + public void synchronousMoveTo(int x, int y) { + LibX11.INSTANCE.XWarpPointer(display, 0, rootWindow, 0, 0, 0, 0, x, y); + LibX11.INSTANCE.XFlush(display); + } + + @Override + public void pressLeft() { + buttonEvent(BTN_LEFT, true); + } + + @Override + public void releaseLeft() { + buttonEvent(BTN_LEFT, false); + } + + @Override + public void pressMiddle() { + buttonEvent(BTN_MIDDLE, true); + } + + @Override + public void releaseMiddle() { + buttonEvent(BTN_MIDDLE, false); + } + + @Override + public void pressRight() { + buttonEvent(BTN_RIGHT, true); + } + + @Override + public void releaseRight() { + buttonEvent(BTN_RIGHT, false); + } + + @Override + public void wheelVerticallyBy(boolean forward, double delta) { + // forward = away from user = scroll up = button 4 + int button = forward ? 4 : 5; + int count = Math.max(1, (int) delta); + for (int i = 0; i < count; i++) { + buttonEvent(button, true); + buttonEvent(button, false); + } + LibX11.INSTANCE.XFlush(display); + } + + @Override + public void wheelHorizontallyBy(boolean forward, double delta) { + // forward = right = button 7 + int button = forward ? 7 : 6; + int count = Math.max(1, (int) delta); + for (int i = 0; i < count; i++) { + buttonEvent(button, true); + buttonEvent(button, false); + } + LibX11.INSTANCE.XFlush(display); + } + + @Override + public void hideCursor() { + if (hiddenCursor == 0) { + byte[] blankData = {0}; + long pixmap = LibX11.INSTANCE.XCreateBitmapFromData(display, rootWindow, blankData, 1, 1); + LibX11.XColor black = new LibX11.XColor(); + hiddenCursor = LibX11.INSTANCE.XCreatePixmapCursor(display, pixmap, pixmap, black, black, 0, 0); + LibX11.INSTANCE.XFreePixmap(display, pixmap); + } + LibX11.INSTANCE.XDefineCursor(display, rootWindow, hiddenCursor); + LibX11.INSTANCE.XFlush(display); + } + + @Override + public void showCursor() { + LibX11.INSTANCE.XUndefineCursor(display, rootWindow); + LibX11.INSTANCE.XFlush(display); + } + + private void buttonEvent(int button, boolean press) { + LibXTest.INSTANCE.XTestFakeButtonEvent(display, button, press ? 1 : 0, 0); + LibX11.INSTANCE.XFlush(display); + } +} From 6a65e680e11b8655aa43845eefe748c3fd43e812 Mon Sep 17 00:00:00 2001 From: SudoWatson Date: Sat, 18 Jul 2026 21:34:23 -0400 Subject: [PATCH 14/23] Fix key events being eaten in wrong mode and notes --- src/main/java/mousemaster/ComboWatcher.java | 31 ++++++++++++------- .../java/mousemaster/KeyboardManager.java | 3 ++ src/main/java/mousemaster/Mousemaster.java | 19 +----------- .../mousemaster/PressKeyEventProcessing.java | 3 ++ 4 files changed, 26 insertions(+), 30 deletions(-) diff --git a/src/main/java/mousemaster/ComboWatcher.java b/src/main/java/mousemaster/ComboWatcher.java index b21345f9..fd523de6 100644 --- a/src/main/java/mousemaster/ComboWatcher.java +++ b/src/main/java/mousemaster/ComboWatcher.java @@ -25,11 +25,14 @@ public class ComboWatcher { private final HintManager hintManager; private final ActiveAppFinder activeAppFinder; private final Clock clock; - private final Set pressedComboPreconditionKeys; private final boolean logRedactKeys; - private final Set unpressedComboPreconditionKeys; + // TODO: this class was modified in commits c85df26/2ad3808 (plus this session, + // uncommitted) to fix Linux key masking. Shared, non-platform code — confirm + // safe/valid here vs. Linux-specific before merge. private final Map> pressedPreconditionKeysByMode; + private final Map> unpressedPreconditionKeysByMode; private Set currentModePressedPreconditionKeys; + private Set currentModeUnpressedPreconditionKeys; private List comboListeners; private List modeListeners; private Mode baseMode; @@ -152,17 +155,12 @@ static Map comboPreparationMinRetainEventCountByMode(ModeMap mode public ComboWatcher(CommandRunner commandRunner, HintManager hintManager, ActiveAppFinder activeAppFinder, Clock clock, - Set unpressedComboPreconditionKeys, - Set pressedComboPreconditionKeys, boolean logRedactKeys, + boolean logRedactKeys, ModeMap modeMap) { this.commandRunner = commandRunner; this.hintManager = hintManager; this.activeAppFinder = activeAppFinder; this.clock = clock; - this.unpressedComboPreconditionKeys = - unpressedComboPreconditionKeys; - this.pressedComboPreconditionKeys = - pressedComboPreconditionKeys; this.logRedactKeys = logRedactKeys; this.comboPreparationRetainDurationByMode = comboPreparationRetainDurationByMode(modeMap); this.comboPreparationMinRetainEventCountByMode = comboPreparationMinRetainEventCountByMode(modeMap); @@ -178,17 +176,24 @@ public ComboWatcher(CommandRunner commandRunner, HintManager hintManager, } this.comboPreparation = ComboPreparation.empty(); Map> preconditionKeysByMode = new HashMap<>(); + Map> unpressedPreconditionKeysByMode = new HashMap<>(); for (Mode mode : modeMap.modes()) { Set keys = new HashSet<>(); + Set unpressedKeys = new HashSet<>(); for (Combo combo : mode.comboMap().commandsByCombo().keySet()) { keys.addAll(combo.precondition() .keyPrecondition() .pressedKeyPrecondition() .allKeys()); + unpressedKeys.addAll(combo.precondition() + .keyPrecondition() + .unpressedKeySet()); } preconditionKeysByMode.put(mode, keys); + unpressedPreconditionKeysByMode.put(mode, unpressedKeys); } this.pressedPreconditionKeysByMode = preconditionKeysByMode; + this.unpressedPreconditionKeysByMode = unpressedPreconditionKeysByMode; } public void setComboListeners(List comboListeners) { @@ -467,9 +472,9 @@ public PressKeyEventProcessingSet keyEvent(KeyEvent event) { } modeJustTimedOut = false; boolean isUnpressedComboPreconditionKey = - unpressedComboPreconditionKeys.contains(event.key()); + currentModeUnpressedPreconditionKeys.contains(event.key()); boolean isPressedComboPreconditionKey = - pressedComboPreconditionKeys.contains(event.key()); + currentModePressedPreconditionKeys.contains(event.key()); boolean isComboPreconditionKey = isUnpressedComboPreconditionKey || isPressedComboPreconditionKey; @@ -832,8 +837,8 @@ else if (match.lastEventAbsorbedByWait() comboPreparationBreaker); // This processingByCombo does not need to have entries about // non-combo sequences (i.e. combo preconditions). - // That is because preconditions are managed by the caller (keyEvent) - // which checks across all modes, not just the current one. (isComboPreconditionKey) + // That is because preconditions are managed by the caller (keyEvent), + // scoped to the current mode's precondition keys. (isComboPreconditionKey) processingByCombo.put(combo, processing); matchByCombo.put(combo, match); } @@ -1383,6 +1388,8 @@ public void modeChanged(Mode newMode) { activeMutations.clear(); currentModePressedPreconditionKeys = pressedPreconditionKeysByMode.getOrDefault(newMode, Set.of()); + currentModeUnpressedPreconditionKeys = + unpressedPreconditionKeysByMode.getOrDefault(newMode, Set.of()); computePreconditionOnlyByPropertyPath(); if (!refreshPreconditionOnlyMutations()) notifyMutatedMode(); diff --git a/src/main/java/mousemaster/KeyboardManager.java b/src/main/java/mousemaster/KeyboardManager.java index 5019165e..a09f02d0 100644 --- a/src/main/java/mousemaster/KeyboardManager.java +++ b/src/main/java/mousemaster/KeyboardManager.java @@ -286,6 +286,9 @@ else if (processingSet.isPartOfComboSequence()) { } } + // TODO: this method, isRetainedByCombos, addRegurgitate, and handleDeadEatingCombos + // below were modified in commits c85df26/2ad3808 to fix Linux key masking. Shared, + // non-platform code — confirm this is safe/valid here vs. Linux-specific before merge. private boolean markOtherKeysOfTheseCombosAsCompleted(List completedCombos, boolean forceIsComboPreparationBreaker) { boolean completedCombosHavePressedKeys = false; diff --git a/src/main/java/mousemaster/Mousemaster.java b/src/main/java/mousemaster/Mousemaster.java index fdf60648..21425b5a 100644 --- a/src/main/java/mousemaster/Mousemaster.java +++ b/src/main/java/mousemaster/Mousemaster.java @@ -185,26 +185,9 @@ private void loadConfiguration(boolean readFile) throws IOException { HintManager hintManager = new HintManager(configuration.maxPositionHistorySize(), screenManager, mouseManager, platform.overlay(), platform.uiAutomation()); commandRunner = new CommandRunner(mouseManager, gridManager, hintManager); - Set unpressedComboPreconditionKeys = new HashSet<>(); - Set pressedComboPreconditionKeys = new HashSet<>(); - for (Mode mode : configuration.modeMap().modes()) { - for (Combo combo : mode.comboMap().commandsByCombo().keySet()) { - unpressedComboPreconditionKeys.addAll(combo.precondition() - .keyPrecondition() - .unpressedKeySet() - .stream() - .toList()); - pressedComboPreconditionKeys.addAll(combo.precondition() - .keyPrecondition() - .pressedKeyPrecondition() - .allKeys()); - } - } ComboWatcher comboWatcher = new ComboWatcher(commandRunner, hintManager, platform.activeAppFinder(), - platform.clock(), - unpressedComboPreconditionKeys, - pressedComboPreconditionKeys, configuration.logRedactKeys(), + platform.clock(), configuration.logRedactKeys(), configuration.modeMap()); keyboardManager = new KeyboardManager(comboWatcher, hintManager, platform.keyRegurgitator()); diff --git a/src/main/java/mousemaster/PressKeyEventProcessing.java b/src/main/java/mousemaster/PressKeyEventProcessing.java index 62d79e65..8efe6869 100644 --- a/src/main/java/mousemaster/PressKeyEventProcessing.java +++ b/src/main/java/mousemaster/PressKeyEventProcessing.java @@ -1,5 +1,8 @@ package mousemaster; +// TODO: modified in commit c85df26 to fix Linux key masking (precondition keys leaking +// to the OS with no Windows-style focus-stealing to hide it). Shared, non-platform code +// affecting Windows too — confirm this is safe/valid here vs. Linux-specific before merge. public enum PressKeyEventProcessing { UNHANDLED, From 3d4358392b8ea56c2c8cf5737efe9913d26578a7 Mon Sep 17 00:00:00 2001 From: SudoWatson Date: Sun, 19 Jul 2026 00:40:38 -0400 Subject: [PATCH 15/23] Mousewheel working --- .../mousemaster/platform/linux/LibUinput.java | 10 +++++-- .../platform/linux/WaylandMouse.java | 29 +++++++++++++++---- .../mousemaster/platform/linux/X11Mouse.java | 26 ++++++++++++++--- 3 files changed, 54 insertions(+), 11 deletions(-) diff --git a/src/main/java/mousemaster/platform/linux/LibUinput.java b/src/main/java/mousemaster/platform/linux/LibUinput.java index 57802a39..d2c66430 100644 --- a/src/main/java/mousemaster/platform/linux/LibUinput.java +++ b/src/main/java/mousemaster/platform/linux/LibUinput.java @@ -23,7 +23,7 @@ public class LibUinput { // Computed via _IOW('U', nr, int) and _IO('U', nr) static final long UI_SET_EVBIT = 0x40045564L; // _IOW('U', 100, int) static final long UI_SET_KEYBIT = 0x40045565L; // _IOW('U', 101, int) - static final long UI_SET_RELBIT = 0x40045567L; // _IOW('U', 103, int) + static final long UI_SET_RELBIT = 0x40045566L; // _IOW('U', 102, int) static final long UI_DEV_CREATE = 0x5501L; // _IO('U', 1) static final long UI_DEV_DESTROY = 0x5502L; // _IO('U', 2) @@ -183,6 +183,12 @@ static NativeLong writeInputEvent(int fd, int type, int code, int value) { event.setShort(16, (short) type); event.setShort(18, (short) code); event.setInt(20, value); - return CLib.INSTANCE.write(fd, event, new NativeLong(INPUT_EVENT_SIZE)); + NativeLong written = CLib.INSTANCE.write(fd, event, new NativeLong(INPUT_EVENT_SIZE)); + if (written.longValue() != INPUT_EVENT_SIZE) { + int errno = Native.getLastError(); + logger.warn("write() for input_event (type={}, code={}, value={}) wrote {} of {} bytes (errno={})", + type, code, value, written.longValue(), INPUT_EVENT_SIZE, errno); + } + return written; } } diff --git a/src/main/java/mousemaster/platform/linux/WaylandMouse.java b/src/main/java/mousemaster/platform/linux/WaylandMouse.java index 4c16d407..d5a21723 100644 --- a/src/main/java/mousemaster/platform/linux/WaylandMouse.java +++ b/src/main/java/mousemaster/platform/linux/WaylandMouse.java @@ -19,9 +19,19 @@ public class WaylandMouse extends LinuxMouse { private static final Logger logger = LoggerFactory.getLogger(WaylandMouse.class); + // MouseManager passes wheel delta in Windows' WHEEL_DELTA convention (120 units = one + // notch, see WindowsMouseController), since that's the shared unit the velocity config + // is tuned against. uinput's REL_WHEEL has no fractional-notch concept - each event is + // a discrete notch - so accumulate sub-notch deltas here and only fire once a full + // notch's worth has built up, carrying the remainder forward (same pattern MouseManager + // itself uses for cursor movement's deltaDistanceX/Y). + private static final double WHEEL_DELTA = 120; + private final LinuxScreens screens; private final int uinputMouseFd; private final LibWaylandClient.WlRegistryListener registryListener; + private double verticalWheelAccumulator; + private double horizontalWheelAccumulator; private Pointer display; private Pointer registry; @@ -268,12 +278,22 @@ public void releaseRight() { @Override public void wheelVerticallyBy(boolean forward, double delta) { - uinputWheel(LibUinput.REL_WHEEL, forward ? 1 : -1, delta); + verticalWheelAccumulator += delta; + int notches = (int) (verticalWheelAccumulator / WHEEL_DELTA); + if (notches <= 0) + return; + verticalWheelAccumulator -= notches * WHEEL_DELTA; + uinputWheel(LibUinput.REL_WHEEL, forward ? 1 : -1, notches); } @Override public void wheelHorizontallyBy(boolean forward, double delta) { - uinputWheel(LibUinput.REL_HWHEEL, forward ? 1 : -1, delta); + horizontalWheelAccumulator += delta; + int notches = (int) (horizontalWheelAccumulator / WHEEL_DELTA); + if (notches <= 0) + return; + horizontalWheelAccumulator -= notches * WHEEL_DELTA; + uinputWheel(LibUinput.REL_HWHEEL, forward ? 1 : -1, notches); } private void uinputButton(int code, int value) { @@ -281,9 +301,8 @@ private void uinputButton(int code, int value) { LibUinput.writeInputEvent(uinputMouseFd, LibUinput.EV_SYN, LibUinput.SYN_REPORT, 0); } - private void uinputWheel(int axisCode, int sign, double delta) { - int count = Math.max(1, (int) delta); - for (int i = 0; i < count; i++) + private void uinputWheel(int axisCode, int sign, int notches) { + for (int i = 0; i < notches; i++) LibUinput.writeInputEvent(uinputMouseFd, LibUinput.EV_REL, axisCode, sign); LibUinput.writeInputEvent(uinputMouseFd, LibUinput.EV_SYN, LibUinput.SYN_REPORT, 0); } diff --git a/src/main/java/mousemaster/platform/linux/X11Mouse.java b/src/main/java/mousemaster/platform/linux/X11Mouse.java index b6f27c9a..9d80b194 100644 --- a/src/main/java/mousemaster/platform/linux/X11Mouse.java +++ b/src/main/java/mousemaster/platform/linux/X11Mouse.java @@ -17,9 +17,19 @@ public class X11Mouse extends LinuxMouse { // Vertical scroll: 4 = up, 5 = down // Horizontal scroll: 6 = left, 7 = right + // MouseManager passes wheel delta in Windows' WHEEL_DELTA convention (120 units = one + // notch, see WindowsMouseController), since that's the shared unit the velocity config + // is tuned against. X11 has no fractional-notch concept - button4/5/6/7 are discrete + // click events - so accumulate sub-notch deltas here and only fire once a full notch's + // worth has built up, carrying the remainder forward (same pattern MouseManager itself + // uses for cursor movement's deltaDistanceX/Y). + private static final double WHEEL_DELTA = 120; + private final Pointer display; private final long rootWindow; private long hiddenCursor = 0; + private double verticalWheelAccumulator; + private double horizontalWheelAccumulator; public X11Mouse(Pointer display, long rootWindow) { this.display = display; @@ -92,8 +102,12 @@ public void releaseRight() { public void wheelVerticallyBy(boolean forward, double delta) { // forward = away from user = scroll up = button 4 int button = forward ? 4 : 5; - int count = Math.max(1, (int) delta); - for (int i = 0; i < count; i++) { + verticalWheelAccumulator += delta; + int notches = (int) (verticalWheelAccumulator / WHEEL_DELTA); + if (notches <= 0) + return; + verticalWheelAccumulator -= notches * WHEEL_DELTA; + for (int i = 0; i < notches; i++) { buttonEvent(button, true); buttonEvent(button, false); } @@ -104,8 +118,12 @@ public void wheelVerticallyBy(boolean forward, double delta) { public void wheelHorizontallyBy(boolean forward, double delta) { // forward = right = button 7 int button = forward ? 7 : 6; - int count = Math.max(1, (int) delta); - for (int i = 0; i < count; i++) { + horizontalWheelAccumulator += delta; + int notches = (int) (horizontalWheelAccumulator / WHEEL_DELTA); + if (notches <= 0) + return; + horizontalWheelAccumulator -= notches * WHEEL_DELTA; + for (int i = 0; i < notches; i++) { buttonEvent(button, true); buttonEvent(button, false); } From 2812701f04cf827a22c0e0baf9be8b95cdb4887d Mon Sep 17 00:00:00 2001 From: SudoWatson Date: Sun, 19 Jul 2026 00:40:44 -0400 Subject: [PATCH 16/23] Remove failsafe --- .../java/mousemaster/platform/linux/LinuxMain.java | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/main/java/mousemaster/platform/linux/LinuxMain.java b/src/main/java/mousemaster/platform/linux/LinuxMain.java index 34694502..a6aa6b92 100644 --- a/src/main/java/mousemaster/platform/linux/LinuxMain.java +++ b/src/main/java/mousemaster/platform/linux/LinuxMain.java @@ -48,18 +48,6 @@ public static void main(String[] args) throws InterruptedException, IOException System.exit(0); }).start(); } - Thread failsafe = new Thread(() -> { - try { - Thread.sleep(300_000); - } catch (InterruptedException ignored) { - return; - } - logger.warn("5-minute failsafe triggered — forcing exit to release keyboard grab"); - System.exit(0); - }, "failsafe-shutdown"); - failsafe.setDaemon(true); - failsafe.start(); - Platform platform = createPlatform(options.multipleInstancesAllowed(), options.keyRegurgitationEnabled(), options.pauseOnError()); logger.info("mousemaster v" + version + " (" + commitId + ") [Linux]"); From c00dfd17df51af7c39b5e5971771f827a123b959 Mon Sep 17 00:00:00 2001 From: SudoWatson Date: Sun, 19 Jul 2026 00:59:54 -0400 Subject: [PATCH 17/23] Remove dev keyboard simulator --- .../linux/LinuxKeyboardSimulator.java | 66 ------------------- .../platform/linux/LinuxPlatform.java | 22 ------- 2 files changed, 88 deletions(-) delete mode 100644 src/main/java/mousemaster/platform/linux/LinuxKeyboardSimulator.java diff --git a/src/main/java/mousemaster/platform/linux/LinuxKeyboardSimulator.java b/src/main/java/mousemaster/platform/linux/LinuxKeyboardSimulator.java deleted file mode 100644 index f409f54d..00000000 --- a/src/main/java/mousemaster/platform/linux/LinuxKeyboardSimulator.java +++ /dev/null @@ -1,66 +0,0 @@ -package mousemaster.platform.linux; - -import org.slf4j.Logger; -import org.slf4j.LoggerFactory; -import java.util.Scanner; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.LinkedBlockingQueue; - -/** - * Temporary keyboard input simulator for testing on Wayland. - * Reads keyboard input from stdin in a separate thread. - * This is a workaround until proper evdev support is implemented. - */ -public class LinuxKeyboardSimulator { - private static final Logger logger = LoggerFactory.getLogger(LinuxKeyboardSimulator.class); - - private final BlockingQueue keyQueue = new LinkedBlockingQueue<>(); - private Thread inputThread; - private volatile boolean running = false; - - public void start() { - if (running) return; - - running = true; - inputThread = new Thread(this::readInput, "KeyboardSimulator"); - inputThread.setDaemon(true); - inputThread.start(); - - logger.info("Keyboard simulator started (reading from stdin)"); - logger.info("Type single letters and press Enter to simulate keypresses"); - } - - public void stop() { - running = false; - if (inputThread != null) { - inputThread.interrupt(); - } - } - - private void readInput() { - try (Scanner scanner = new Scanner(System.in)) { - while (running) { - if (scanner.hasNextLine()) { - String line = scanner.nextLine().trim(); - if (!line.isEmpty()) { - for (char c : line.toCharArray()) { - String key = String.valueOf(c); - keyQueue.offer(key.toLowerCase()); - logger.debug("Queued key: {}", key); - } - } - } - } - } catch (Exception e) { - logger.error("Error reading keyboard input", e); - } - } - - public String pollKey() { - return keyQueue.poll(); - } - - public boolean hasKeys() { - return !keyQueue.isEmpty(); - } -} diff --git a/src/main/java/mousemaster/platform/linux/LinuxPlatform.java b/src/main/java/mousemaster/platform/linux/LinuxPlatform.java index 7073a954..b63c9d6f 100644 --- a/src/main/java/mousemaster/platform/linux/LinuxPlatform.java +++ b/src/main/java/mousemaster/platform/linux/LinuxPlatform.java @@ -40,7 +40,6 @@ public class LinuxPlatform implements Platform { private final boolean isWayland; private Integer lastMouseX; private Integer lastMouseY; - private LinuxKeyboardSimulator keyboardSimulator; public LinuxPlatform(boolean multipleInstancesAllowed, boolean keyRegurgitationEnabled) { logger.info("Initializing LinuxPlatform"); @@ -50,13 +49,6 @@ public LinuxPlatform(boolean multipleInstancesAllowed, boolean keyRegurgitationE String waylandDisplay = System.getenv("WAYLAND_DISPLAY"); isWayland = "wayland".equals(sessionType) || waylandDisplay != null; - if (isWayland) { - logger.warn("Running under Wayland - keyboard grabbing will use simulator mode"); - logger.warn("For production use, evdev-based input handling is required"); - keyboardSimulator = new LinuxKeyboardSimulator(); - keyboardSimulator.start(); - } - // Open X11 display connection (works even under XWayland for rendering) display = LibX11.INSTANCE.XOpenDisplay(null); if (display == null) { @@ -100,16 +92,6 @@ public void update(double delta) { @Override public void pumpEvents() { - if (isWayland && keyboardSimulator != null && keyboardSimulator.hasKeys()) { - String keysym = keyboardSimulator.pollKey(); - while (keysym != null) { - Key key = LinuxVirtualKey.fromKeysym(keysym); - if (key != null && keyboardManager != null) - handleKeyEvent(new KeyEvent.PressKeyEvent(Instant.now(), key)); - keysym = keyboardSimulator.pollKey(); - } - } - KeyEvent event = evdev.pollEvent(); while (event != null) { logger.debug("evdev: {}", event); @@ -200,10 +182,6 @@ public void reset(MouseManager mouseManager, KeyboardManager keyboardManager, public void shutdown() { logger.info("Shutting down LinuxPlatform"); - if (keyboardSimulator != null) { - keyboardSimulator.stop(); - } - evdev.destroy(); keyboard.destroy(); mouse.destroy(); From 4c83fce19f97b92b82d271e07800e749863890ea Mon Sep 17 00:00:00 2001 From: SudoWatson Date: Sun, 19 Jul 2026 18:01:17 -0400 Subject: [PATCH 18/23] X11 Zoom --- .../platform/linux/LinuxOverlay.java | 111 +++++++++++++++++- src/main/java/mousemaster/qt/ZoomWindow.java | 90 ++++++++++++++ 2 files changed, 196 insertions(+), 5 deletions(-) create mode 100644 src/main/java/mousemaster/qt/ZoomWindow.java diff --git a/src/main/java/mousemaster/platform/linux/LinuxOverlay.java b/src/main/java/mousemaster/platform/linux/LinuxOverlay.java index 1675180f..68517c15 100644 --- a/src/main/java/mousemaster/platform/linux/LinuxOverlay.java +++ b/src/main/java/mousemaster/platform/linux/LinuxOverlay.java @@ -1,10 +1,13 @@ package mousemaster.platform.linux; import com.sun.jna.Pointer; +import io.qt.gui.QPixmap; +import io.qt.widgets.QApplication; import mousemaster.*; import mousemaster.platform.Overlay; import mousemaster.qt.GridWindow; import mousemaster.qt.HintMeshWindow; +import mousemaster.qt.ZoomWindow; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -13,7 +16,9 @@ /** * Linux overlay implementation. Delegates rendering to Qt-based GridWindow and HintMeshWindow. - * Zoom and indicator features are stubs pending future implementation. + * Zoom is implemented via a captured screenshot rendered magnified in ZoomWindow, since + * Linux has no equivalent to the Win32 Magnification API. Indicator is still a stub + * pending future implementation. */ public class LinuxOverlay implements Overlay { @@ -23,6 +28,12 @@ public class LinuxOverlay implements Overlay { private Runnable messagePump; private GridWindow gridWindow; private HintMeshWindow hintMeshWindow; + private ZoomWindow zoomWindow; + + private Rectangle pendingCaptureRect; + private Zoom pendingCaptureZoom; + private boolean pendingCaptureGridWasVisible; + private boolean pendingCaptureHintWasVisible; public LinuxOverlay(Pointer display) { this.display = display; @@ -30,6 +41,32 @@ public LinuxOverlay(Pointer display) { @Override public void update(double delta) { + if (pendingCaptureRect == null) + return; + // The hide() calls in requestScreenshotCapture() only ran on a previous tick; + // QtManager.processEvents() at the top of *this* tick (see Mousemaster's main + // loop - platform.update() runs before zoomManager.update(), so a capture + // requested during zoom's update is only fulfilled here, one tick later) has + // now flushed them, so the windows are actually gone from the screen and it's + // safe to grab. Capturing in the same tick the hide() was issued would race + // X11 and often capture the not-yet-unmapped windows. + QPixmap pixmap = QApplication.primaryScreen() + .grabWindow(0, pendingCaptureRect.x(), + pendingCaptureRect.y(), + pendingCaptureRect.width(), + pendingCaptureRect.height()); + if (pendingCaptureGridWasVisible) + gridWindow.show(); + if (pendingCaptureHintWasVisible) + hintMeshWindow.show(); + if (zoomWindow == null) + zoomWindow = new ZoomWindow(); + zoomWindow.setScreenshot(pixmap, pendingCaptureRect); + zoomWindow.setZoom(pendingCaptureZoom); + zoomWindow.show(); + raiseOverlayWindows(); + pendingCaptureRect = null; + pendingCaptureZoom = null; } @Override @@ -81,6 +118,7 @@ public void setGrid(Grid grid) { if (gridWindow == null) gridWindow = new GridWindow(); gridWindow.setGrid(grid); + gridWindow.raise(); logger.debug("Grid displayed: {}x{} at ({},{}) size {}x{}", grid.columnCount(), grid.rowCount(), grid.x(), grid.y(), grid.width(), grid.height()); @@ -97,6 +135,7 @@ public void setHintMesh(HintMesh hintMesh, Zoom zoom) { if (hintMeshWindow == null) hintMeshWindow = new HintMeshWindow(); hintMeshWindow.setHintMesh(hintMesh); + hintMeshWindow.raise(); logger.debug("Hint mesh displayed with {} hints", hintMesh.hints().size()); } @@ -118,22 +157,84 @@ public void animateHintMatch(Hint hint) { @Override public void setZoom(Zoom zoom) { - logger.debug("setZoom() called"); + if (zoom == null) { + cancelPendingCapture(); + if (zoomWindow != null) + zoomWindow.clear(); + return; + } + requestScreenshotCapture(zoom.screenRectangle(), zoom); + logger.debug("Zoom requested: {}x at {}", zoom.percent(), zoom.center()); } @Override public void startScreenshotZoomAnimation(Rectangle screenRect, Zoom beginZoom) { - logger.debug("startScreenshotZoomAnimation() called"); + requestScreenshotCapture(screenRect, beginZoom); + logger.debug("Screenshot zoom animation requested at {}x", beginZoom.percent()); } @Override public void updateScreenshotZoom(Zoom zoom) { - logger.debug("updateScreenshotZoom() called"); + if (zoomWindow == null) + return; + zoomWindow.setZoom(zoom); + zoomWindow.update(); } @Override public void endScreenshotZoomAnimation(Zoom finalZoom) { - logger.debug("endScreenshotZoomAnimation() called"); + if (zoomWindow == null) + return; + if (finalZoom == null) { + zoomWindow.clear(); + return; + } + zoomWindow.setZoom(finalZoom); + zoomWindow.update(); + raiseOverlayWindows(); + } + + /** + * Hides our own overlay windows (which must not appear in the captured backdrop - + * Linux has no capture-exclusion API like Windows' WDA_EXCLUDEFROMCAPTURE/ + * MagSetWindowFilterList) and records the capture to perform. The actual + * grabWindow() call happens on the next tick's update(), once the hide has + * actually been flushed - see the comment there. + */ + private void requestScreenshotCapture(Rectangle rect, Zoom zoom) { + if (pendingCaptureRect == null) { + pendingCaptureGridWasVisible = gridWindow != null && gridWindow.isVisible(); + pendingCaptureHintWasVisible = + hintMeshWindow != null && hintMeshWindow.isVisible(); + if (pendingCaptureGridWasVisible) + gridWindow.hide(); + if (pendingCaptureHintWasVisible) + hintMeshWindow.hide(); + if (zoomWindow != null) + zoomWindow.hide(); + } + pendingCaptureRect = rect; + pendingCaptureZoom = zoom; + } + + /** Aborts a capture requested but not yet fulfilled, restoring window visibility. */ + private void cancelPendingCapture() { + if (pendingCaptureRect == null) + return; + if (pendingCaptureGridWasVisible) + gridWindow.show(); + if (pendingCaptureHintWasVisible) + hintMeshWindow.show(); + pendingCaptureRect = null; + pendingCaptureZoom = null; + } + + /** Keeps hints/grid stacked above the zoom backdrop. */ + private void raiseOverlayWindows() { + if (gridWindow != null) + gridWindow.raise(); + if (hintMeshWindow != null) + hintMeshWindow.raise(); } @Override diff --git a/src/main/java/mousemaster/qt/ZoomWindow.java b/src/main/java/mousemaster/qt/ZoomWindow.java new file mode 100644 index 00000000..d56745b4 --- /dev/null +++ b/src/main/java/mousemaster/qt/ZoomWindow.java @@ -0,0 +1,90 @@ +package mousemaster.qt; + +import io.qt.core.*; +import io.qt.gui.*; +import mousemaster.Rectangle; +import mousemaster.Zoom; + +/** + * Renders a captured screenshot magnified around a zoom center, standing in for the + * Win32 Magnification API used on Windows (no equivalent live/optical magnifier exists + * on Linux). Used for both the animated zoom transition and the resulting static zoom. + */ +public class ZoomWindow extends TransparentWindow { + + private QPixmap screenshot; + private Rectangle screenRect; + private Zoom zoom; + + public ZoomWindow() { + super(); + } + + public void setScreenshot(QPixmap screenshot, Rectangle screenRect) { + if (this.screenshot != null) + this.screenshot.dispose(); + this.screenshot = screenshot; + this.screenRect = screenRect; + setGeometry(screenRect.x(), screenRect.y(), screenRect.width(), + screenRect.height()); + } + + public void setZoom(Zoom zoom) { + this.zoom = zoom; + } + + public void clear() { + if (screenshot != null) { + screenshot.dispose(); + screenshot = null; + } + zoom = null; + hide(); + } + + @Override + protected void paintEvent(QPaintEvent event) { + if (screenshot == null || zoom == null) + return; + + QPainter painter = new QPainter(this); + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, true); + painter.fillRect(0, 0, width(), height(), QColor.fromRgb(0, 0, 0)); + + double localCenterX = zoom.center().x() - screenRect.x(); + double localCenterY = zoom.center().y() - screenRect.y(); + double sourceWidth = screenRect.width() / zoom.percent(); + double sourceHeight = screenRect.height() / zoom.percent(); + double sourceX = localCenterX - sourceWidth / 2; + double sourceY = localCenterY - sourceHeight / 2; + + // Near a screen edge/corner, the ideal source rect can extend past the + // captured pixmap's bounds. Clamp it and shrink the target rect by the same + // scale so the valid pixels still land in the right place, and the region + // with no captured pixels stays as the black fill above instead of leaving + // a gap where drawPixmap would otherwise paint nothing (transparent, letting + // the real desktop show through). + double scale = zoom.percent(); + double clampedSourceX = Math.max(sourceX, 0); + double clampedSourceY = Math.max(sourceY, 0); + double clampedSourceRight = Math.min(sourceX + sourceWidth, screenshot.width()); + double clampedSourceBottom = + Math.min(sourceY + sourceHeight, screenshot.height()); + double clampedSourceWidth = Math.max(0, clampedSourceRight - clampedSourceX); + double clampedSourceHeight = Math.max(0, clampedSourceBottom - clampedSourceY); + + if (clampedSourceWidth > 0 && clampedSourceHeight > 0) { + double targetX = (clampedSourceX - sourceX) * scale; + double targetY = (clampedSourceY - sourceY) * scale; + QRectF sourceRect = new QRectF(clampedSourceX, clampedSourceY, + clampedSourceWidth, clampedSourceHeight); + QRectF targetRect = new QRectF(targetX, targetY, + clampedSourceWidth * scale, clampedSourceHeight * scale); + painter.drawPixmap(targetRect, screenshot, sourceRect); + } + + painter.end(); + painter.dispose(); + } + +} From eba679532b12f0987a8c91a5a8a46b24e81c4379 Mon Sep 17 00:00:00 2001 From: Austin Lennert <65475597+SudoWatson@users.noreply.github.com> Date: Mon, 20 Jul 2026 05:26:27 -0400 Subject: [PATCH 19/23] Merge in Overlay Overhaul (#2) * Fingerprint SendInput with dwExtraInfo and change default value of --ignore-injected-events to false (#66) * Reimplement drawing of grid with Qt * Implement grid transition animation * Add grid line opacity, background, and fade animation * Fade the grid from its current opacity with InOutQuad easing, and fade a new grid in when it replaces one mid fade-out * Change default grid background color and opacity to red 0.1 * Update GraalVM reflect config after changes to grid rendering * Build 89 - **Grid drawn with Qt, with animations and styling**: The grid is now rendered with Qt, like the hints and the indicator, instead of the old GDI drawing. The grid has an optional transition animation when it moves or resizes, and configurable line opacity and background fill. The animation is enabled by default with a 100ms duration. ```properties grid-mode.grid.transition-animation-enabled=true grid-mode.grid.transition-animation-duration-millis=100 grid-mode.grid.fade-animation-enabled=true grid-mode.grid.fade-animation-duration-millis=100 grid-mode.grid.line-opacity=1.0 grid-mode.grid.background-color=#FF0000 grid-mode.grid.background-opacity=0.1 ``` https://github.com/user-attachments/assets/b395505e-9a06-4ec8-b361-81323c7b3fb4 - **Injected event fingerprinting**: mousemaster now tags its own injected input with a fingerprint, so it can tell its own events apart from events injected by other software (like kanata). * Add fit cell sizing and last-selected-hint-cell grid area for recursive hint grids hint.grid-cell-sizing=fit divides the hint area into exactly grid-max-row/column-count cells (pixel sizes ignored); grid-area=last-selected-hint-cell narrows the grid to the last selected cell. Together they enable neru-style recursive grids built as one mode per depth level. Adds neru.properties and an author.properties recursive-hint chain, plus a reference-doc note. * Fix rounding issue when drawing subgrid * Add subgrid-closed option and fix open subgrid interior lines * Draw the subgrid as a nested hint mesh * Extract Qt color and font helpers out of WindowsOverlay * Extract GridRenderer out of WindowsOverlay * Extract IndicatorRenderer and shared StackedShadowEffect out of WindowsOverlay * Extract ScreenshotWidget out of WindowsOverlay * Extract HintMeshRenderer out of WindowsOverlay * Re-resolve grid-cell-width/height on variable mutation by pointing mutation paths at the nested cellSizing record In author configuration, hint3 zoomed would break. * Add subsubgrid for neru-like dots in the recursive grid * Implement built-in combo variable isidling * Move cross-platform indicator detection and positioning into IndicatorRenderer * Move the renderer classes out of the qt package into a dedicated renderer package * Port zoom edge-clamp fix into shared qt/ScreenshotWidget Our Linux-only qt/ZoomWindow (now being retired in favor of the shared ScreenshotWidget from petoncle/main's overlay refactor) had a hardware-confirmed fix: clamp the zoom source rect to the captured pixmap's bounds and shrink the target rect by the same scale, so regions with no captured pixels stay black instead of leaving a transparent gap near screen edges/corners. ScreenshotWidget lacked this. Port it in before consolidating Linux onto this shared class, since Linux uses it as the sole, persistent zoom-rendering mechanism (no live-magnifier handoff like Windows has), so the gap would be visible in steady state rather than only during a brief transition. * Rewrite LinuxOverlay onto petoncle/main's shared renderer architecture Replace our hand-rolled qt/GridWindow, qt/HintMeshWindow, qt/ZoomWindow (deleted in the previous commit) with the same platform-agnostic GridRenderer, HintMeshRenderer, IndicatorRenderer, and ScreenshotWidget that WindowsOverlay now uses post-refactor. LinuxOverlay becomes a thin adapter: it owns the renderer instances, supplies TransparentWindow via the existing X11-flagged base class, and handles only X11-specific window management (real setTopmost() via Qt raise(), plus applying the same window flags to GridRenderer's/ScreenshotWidget's own widgets, which - being shared with Windows - only set FramelessWindowHint themselves and rely on the platform to add topmost/click-through styling, the way Windows does via native WS_EX_* calls). This fixes a real bug for free: our old HintMeshWindow silently dropped the Zoom parameter passed to setHintMesh(), which HANDOFF.md root-caused as the cause of second-level hint overlays rendering at screen-center instead of the zoomed location, and third-level/history-mode hints being unreadably small. HintMeshRenderer correctly transforms hint-box geometry by Zoom internally. It also adds a real mouse indicator to Linux for the first time - setIndicator()/hideIndicator() were previously complete no-op stubs. Since MouseController has no cursorVisualCenter()/mouseSize() lookup on Linux, a fixed cursor-size fallback is used for now (mirrors Windows' own fallback-when-lookup-fails pattern); a real XFixesGetCursorImage lookup is a follow-up. Active-screen resolution reuses the existing, already platform-agnostic ScreenManager.nearestScreenContaining() rather than adding new X11 code. The one-tick-deferred screenshot-capture mechanism for zoom (hide our own windows, grab the screen one tick later once the hide is actually flushed, since Linux has no capture-exclusion API like Windows' WDA_EXCLUDEFROMCAPTURE) is preserved, just re-targeted at the new renderers' windows instead of the old bespoke classes. LinuxPlatform gains lastMouseX()/lastMouseY() getters (exposing the XQueryPointer-polled position already tracked for Fix 4's mouse-move listener notifications) for the indicator glue, forwards mouse moves to LinuxOverlay.mouseMoved() for live indicator repositioning (Windows does this via its low-level hook; X11 has no such hook), and now calls overlay.setTopmost() on a periodic timer mirroring WindowsPlatform's own 200ms re-assertion against WMs that don't fully respect WindowStaysOnTopHint. * Fix overlay windows swallowing all clicks by making them click-through GridRenderer/HintMeshRenderer's hide() calls never actually unmap the underlying window - they only clear the drawn content and reset opacity, leaving the real window mapped indefinitely once first shown (GridRenderer's widget spans the entire virtual desktop; HintMeshRenderer's per-screen windows span entire screens). This is safe by design on Windows, where WS_EX_TRANSPARENT is applied once at HWND creation, making the window permanently click-through regardless of its "logical" visibility. The Linux port never added the Qt equivalent (WA_TransparentForMouseEvents), so the first time any grid or hint mesh was shown, its window became a permanent, invisible, full-desktop click blocker - swallowing every click (both our own synthetic XTestFakeButtonEvent clicks and real physical clicks) from that point forward, in every mode including idle-mode, until the process was killed. Fix: set WA_TransparentForMouseEvents in TransparentWindow's constructor (covers indicator + hint-mesh windows, both created via TransparentWindow) and in LinuxOverlay.applyX11OverlayFlags() (covers GridRenderer's widget and ScreenshotWidget, which are shared with Windows and don't extend TransparentWindow). Found via hardware testing: clicking (both program-driven and physical trackpad) stopped working entirely as soon as any hint or grid overlay had been shown once, in any mode afterward. * Make overlay windows click-through via the X11 Shape extension Qt's WA_TransparentForMouseEvents attribute (added in the previous commit) turned out not to be enough on its own for these frameless, override-redirect (X11BypassWindowManagerHint) windows - confirmed via hardware testing that clicks still didn't pass through. Add real, guaranteed click-through by clearing each overlay window's X11 input shape directly via the XShape extension (XShapeCombineRectangles with a null/empty rectangle list on ShapeInput) - the same mechanism compositors and other click-through overlay tools use, independent of Qt's platform-plugin behavior for this specific window configuration. Applied uniformly to all four overlay window types via applyX11OverlayFlags(): the indicator window, each per-screen hint mesh window (via a new createStyledHintMeshWindow() factory passed to HintMeshRenderer, mirroring WindowsOverlay's own factory pattern), GridRenderer's widget, and ScreenshotWidget. * Fix indicator doubled position * Fix modifier keys being blocked --------- Co-authored-by: petoncle --- configuration/author.properties | 147 +- configuration/combo-reference.md | 13 + configuration/configuration-reference.md | 46 + pom.xml | 2 +- recursive-hint-grid.md | 98 + .../java/mousemaster/ApplicationOptions.java | 6 +- .../java/mousemaster/BuiltInVariable.java | 24 + src/main/java/mousemaster/ComboWatcher.java | 41 +- .../java/mousemaster/ConfigurationParser.java | 251 +- src/main/java/mousemaster/Grid.java | 88 +- .../java/mousemaster/GridConfiguration.java | 84 +- src/main/java/mousemaster/GridManager.java | 9 +- src/main/java/mousemaster/HintCellSizing.java | 19 + src/main/java/mousemaster/HintGridArea.java | 11 +- src/main/java/mousemaster/HintGridLayout.java | 49 +- src/main/java/mousemaster/HintManager.java | 197 +- src/main/java/mousemaster/HintMesh.java | 15 +- src/main/java/mousemaster/HintMeshStyle.java | 184 +- .../java/mousemaster/KeyboardManager.java | 9 +- src/main/java/mousemaster/ModeController.java | 1 + src/main/java/mousemaster/ZoomManager.java | 14 +- .../mousemaster/platform/linux/LibXShape.java | 31 + .../platform/linux/LinuxOverlay.java | 331 +- .../platform/linux/LinuxPlatform.java | 25 +- .../windows/WindowsKeyboardController.java | 17 + .../platform/windows/WindowsMain.java | 9 +- .../platform/windows/WindowsOverlay.java | 4203 +---------------- .../platform/windows/WindowsPlatform.java | 20 +- .../java/mousemaster/qt/FadeAnimator.java | 50 +- src/main/java/mousemaster/qt/GridWindow.java | 88 - .../java/mousemaster/qt/HintMeshWindow.java | 101 - src/main/java/mousemaster/qt/QtColorUtil.java | 80 + src/main/java/mousemaster/qt/QtFontStyle.java | 22 + src/main/java/mousemaster/qt/QtHintFont.java | 205 + .../java/mousemaster/qt/QtHintFontStyle.java | 26 + ...{ZoomWindow.java => ScreenshotWidget.java} | 78 +- .../mousemaster/qt/StackedShadowEffect.java | 185 + .../mousemaster/qt/TransparentWindow.java | 13 +- .../mousemaster/renderer/GridRenderer.java | 256 + .../renderer/HintMeshRenderer.java | 2276 +++++++++ .../renderer/IndicatorRenderer.java | 988 ++++ .../META-INF/native-image/jni-config.json | 31 +- .../META-INF/native-image/reflect-config.json | 275 +- .../IsIdlingBuiltInVariableTest.java | 48 + 44 files changed, 6135 insertions(+), 4531 deletions(-) create mode 100644 recursive-hint-grid.md create mode 100644 src/main/java/mousemaster/BuiltInVariable.java create mode 100644 src/main/java/mousemaster/HintCellSizing.java create mode 100644 src/main/java/mousemaster/platform/linux/LibXShape.java delete mode 100644 src/main/java/mousemaster/qt/GridWindow.java delete mode 100644 src/main/java/mousemaster/qt/HintMeshWindow.java create mode 100644 src/main/java/mousemaster/qt/QtColorUtil.java create mode 100644 src/main/java/mousemaster/qt/QtFontStyle.java create mode 100644 src/main/java/mousemaster/qt/QtHintFont.java create mode 100644 src/main/java/mousemaster/qt/QtHintFontStyle.java rename src/main/java/mousemaster/qt/{ZoomWindow.java => ScreenshotWidget.java} (52%) create mode 100644 src/main/java/mousemaster/qt/StackedShadowEffect.java create mode 100644 src/main/java/mousemaster/renderer/GridRenderer.java create mode 100644 src/main/java/mousemaster/renderer/HintMeshRenderer.java create mode 100644 src/main/java/mousemaster/renderer/IndicatorRenderer.java create mode 100644 src/test/java/mousemaster/IsIdlingBuiltInVariableTest.java diff --git a/configuration/author.properties b/configuration/author.properties index aa54ea2a..ff68162d 100644 --- a/configuration/author.properties +++ b/configuration/author.properties @@ -13,6 +13,7 @@ key-alias.left.uk-qwerty=j key-alias.right.uk-qwerty=l key-alias.directionkey.uk-qwerty=i k j l key-alias.hintkey.uk-qwerty=i j k l m o +key-alias.hintandrecgridkey.uk-qwerty=hintkey recgridkey key-alias.hint1key.uk-qwerty=i j k l m o key-alias.hint2key.uk-qwerty=i j k l m o key-alias.hintleftbutton.uk-qwerty=space rightalt @@ -63,7 +64,7 @@ normal-mode.noop.capslocknoop=#capslock #normal-mode.indicator.idle.shadow-opacity=0 #normal-mode.indicator.idle.shadow-horizontal-offset=0 #normal-mode.indicator.idle.shadow-vertical-offset=0 -##normal-mode.indicator.idle.label-text=n +#normal-mode.indicator.idle.label-text=n #normal-mode.indicator.move.color=#FF0000 #normal-mode.indicator.wheel.color=#FFFF00 #normal-mode.indicator.wheel.inner-outline-color=#FFFF00 @@ -81,10 +82,29 @@ normal-mode.noop.capslocknoop=#capslock #normal-mode.indicator.right-mouse-press.inner-outline-color=#00FFFF #normal-mode.indicator.right-mouse-press.shadow-opacity=1 #normal-mode.indicator.right-mouse-press.shadow-color=#00FFFF -normal-mode.timeout.duration-millis=5000 -normal-mode.timeout.mode=idle-mode # Do not eat the rightctrl key press event (#) so it can be used by other apps. # ^{up down left right} requires that up, down, left, and right are unpressed. +normal-mode.indicator.idle.label-font-color=#000000 +normal-mode.indicator.idle.label-font-size=16 +normal-mode.to.normal-timeout4-mode=_{isidling} wait-1000 +_normal-timeout-mode=normal-mode +_normal-timeout-mode.hide-cursor.enabled=true +# Override the inherited normal-mode.to.normal-timeout4-mode. +_normal-timeout-mode.to.normal-timeout4-mode=+f24 +_normal-timeout-mode.to.normal-mode=_{!isidling} +normal-mode.break-combo-preparation=+middlebutton-0-150 -middlebutton-1 +normal-timeout4-mode=_normal-timeout-mode +normal-timeout4-mode.indicator.idle.label-text=4 +normal-timeout4-mode.to.normal-timeout3-mode=wait-1000 +normal-timeout3-mode=_normal-timeout-mode +normal-timeout3-mode.indicator.idle.label-text=3 +normal-timeout3-mode.to.normal-timeout2-mode=wait-1000 +normal-timeout2-mode=_normal-timeout-mode +normal-timeout2-mode.indicator.idle.label-text=2 +normal-timeout2-mode.to.normal-timeout1-mode=wait-1000 +normal-timeout1-mode=_normal-timeout-mode +normal-timeout1-mode.indicator.idle.label-text=1 +normal-timeout1-mode.to.idle-mode=wait-1000 normal-mode.to.idle-mode=#rightctrl | #hintback normal-mode.mouse.initial-velocity=200 normal-mode.mouse.max-velocity=1200 @@ -125,8 +145,8 @@ position-history-mode.hint.font-shadow-opacity=0 position-history-mode.hint.font-name=Consolas position-history-mode.hint.font-shadow-horizontal-offset=1 position-history-mode.hint.font-shadow-vertical-offset=1 -position-history-mode.hint.subgrid-column-count=1 -position-history-mode.hint.subgrid-row-count=1 +position-history-mode.hint.subgrid-max-column-count=1 +position-history-mode.hint.subgrid-max-row-count=1 position-history-mode.hint.font-size=20 position-history-mode.hint.box-opacity=0.2 position-history-mode.hint.box-border-thickness=2 @@ -222,6 +242,7 @@ normal-mode.to.temp-screen-snap-mode=^{directionkey} +rightalt | ^{directionkey} idle-mode.to.screen-grid-mode=_{none | modifierkey} +rightalt-250 # temp-screen-snap-mode times out to screen-grid-mode after 250ms, # unless an arrow key is pressed (then, it switches to screen-snap-mode) +temp-screen-snap-mode.grid=screen-grid-mode.grid temp-screen-snap-mode.indicator=normal-mode.indicator temp-screen-snap-mode.mouse=normal-mode.mouse temp-screen-snap-mode.to.normal-mode=^{rightbutton} -rightalt | -rightalt -rightbutton @@ -269,6 +290,13 @@ screen-grid-mode.grid.row-count=2 screen-grid-mode.grid.column-count=2 screen-grid-mode.grid.line-visible=true screen-grid-mode.grid.line-thickness=2 +screen-grid-mode.grid.transition-animation-enabled=true +screen-grid-mode.grid.transition-animation-duration-millis=100 +screen-grid-mode.grid.fade-animation-enabled=true +screen-grid-mode.grid.fade-animation-duration-millis=100 +screen-grid-mode.grid.line-opacity=1 +screen-grid-mode.grid.background-color=#FF0000 +screen-grid-mode.grid.background-opacity=0.1 screen-grid-mode.grid.line-color=#FF0000 screen-grid-mode.move-grid.up=_{leftshift} +up screen-grid-mode.move-grid.down=_{leftshift} +down @@ -394,8 +422,8 @@ _hint-mode.hint.grid-cell-height.2880x1920-200%=160 _hint-mode.hint.grid-cell-width.1920x1080-150%=213 _hint-mode.hint.grid-cell-height.1920x1080-150%=120 -_hint-mode.hint.subgrid-row-count=2 -_hint-mode.hint.subgrid-column-count=2 +_hint-mode.hint.subgrid-max-row-count=2 +_hint-mode.hint.subgrid-max-column-count=2 _hint-mode.hint.subgrid-border-thickness=2 _hint-mode.hint.subgrid-border-length=10 _hint-mode.hint.subgrid-border-thickness.3840x2160-300%=4 @@ -557,22 +585,23 @@ hint3-mode.zoom.center=screen-center | _{iszoom} -> last-selected-hint hint3-mode.to.hint2-mode=+hintback hint3-mode.to.previous-mode-from-history-stack=_{rightalt} +esc | _{rightalt} #rightctrl hint3-mode.to.idle-mode=^{rightalt} +esc | ^{rightalt} #rightctrl -click-after-hint-mode.press.left=_{!ismclick !isrclick} _{none | modifierkey} +hintkey | _{!ismclick !isrclick} +leftbutton | _{isnohintclick !ismclick !isrclick} -leftbutton +click-after-hint-mode.press.left=_{!ismclick !isrclick} _{none | modifierkey} +hintandrecgridkey | _{!ismclick !isrclick} +leftbutton | _{isnohintclick !ismclick !isrclick} -leftbutton click-after-hint-mode.indicator=normal-mode.indicator -# Release left button only if hintkey is held for shorter than 250ms. -click-after-hint-mode.release.left=_{!ismclick !isrclick} -hintkey | _{!ismclick !isrclick} ^{hintkey} -leftbutton -click-after-hint-mode.press.middle=_{ismclick} _{none | modifierkey} +hintkey | _{isnohintclick ismclick} +middlebutton -click-after-hint-mode.release.middle=_{ismclick} -hintkey | _{isnohintclick ismclick} -middlebutton -click-after-hint-mode.press.right=_{isrclick} _{none | modifierkey} +hintkey | _{isnohintclick isrclick} +rightbutton -click-after-hint-mode.release.right=_{isrclick} -hintkey | _{isnohintclick isrclick} -rightbutton +# Release left button only if hintandrecgridkey is held for shorter than 250ms. +click-after-hint-mode.release.left=_{!ismclick !isrclick} -hintandrecgridkey | _{!ismclick !isrclick} ^{hintandrecgridkey} -leftbutton +click-after-hint-mode.press.middle=_{ismclick} _{none | modifierkey} +hintandrecgridkey | _{isnohintclick ismclick} +middlebutton +click-after-hint-mode.release.middle=_{ismclick} -hintandrecgridkey | _{isnohintclick ismclick} -middlebutton +click-after-hint-mode.press.right=_{isrclick} _{none | modifierkey} +hintandrecgridkey | _{isnohintclick isrclick} +rightbutton +click-after-hint-mode.release.right=_{isrclick} -hintandrecgridkey | _{isnohintclick isrclick} -rightbutton #click-after-hint-mode.timeout.duration-millis=250 #click-after-hint-mode.timeout.mode=hint1-mode click-after-hint-mode.to.hold-after-hint-mode=_{isunsethintvarshold} wait-0 -click-after-hint-mode.to.hint1-mode=_{isunsethintvars} wait-0 -click-after-hint-mode.set-variable.isunsethintvars=_{!isnohintclick | isnohintclick islclick} ^{hintkey} wait-250 \ +click-after-hint-mode.to.hint1-mode=_{isunsethintvars !isrecursivehint} wait-0 +click-after-hint-mode.to.recursive-hint1-mode=_{isunsethintvars isrecursivehint} wait-0 +click-after-hint-mode.set-variable.isunsethintvars=_{!isnohintclick | isnohintclick islclick} ^{hintandrecgridkey} wait-250 \ | _{isnohintclick ismclick} -middlebutton \ | _{isnohintclick isrclick} -rightbutton -click-after-hint-mode.set-variable.isunsethintvarshold=+hintkey-250 | +leftbutton-250 +click-after-hint-mode.set-variable.isunsethintvarshold=+hintandrecgridkey-250 | +leftbutton-250 click-after-hint-mode.unset-variable.isunsethintvars=_{isunsethintvars} wait-0 click-after-hint-mode.unset-variable.isunsethintvarshold=_{isunsethintvarshold} wait-0 click-after-hint-mode.set-variable.isnomove=_{isunsethintvars | isunsethintvarshold} wait-0 @@ -586,7 +615,8 @@ click-after-hint-mode.indicator.enabled=true | _{islevel3 iszoom} -> false # Timeout will not be triggered if left button is pressed. click-after-hint-mode.position-history.save-position=_{isunsethintvarshold} wait-0 | _{isunsethintvars} wait-0 | +rightctrl | +hintback hold-after-hint-mode.indicator=normal-mode.indicator -hold-after-hint-mode.to.hint1-mode=^{hintkey leftbutton} +hold-after-hint-mode.to.hint1-mode=^{hintandrecgridkey leftbutton} _{!isrecursivehint} +hold-after-hint-mode.to.recursive-hint1-mode=^{hintandrecgridkey leftbutton} _{isrecursivehint} after-copy-paste-hint-mode.indicator=normal-mode.indicator after-copy-paste-hint-mode.timeout.duration-millis=250 after-copy-paste-hint-mode.timeout.mode=hint1-mode @@ -926,9 +956,78 @@ oneshot-mode.to.typing-mode=_{typingkeys} #{*}-2000 \ oneshot-mode.to.idle-mode=^{typingkeys} #{*}-2000 | #rightctrl oneshot-mode.macro.redo-doubletap=-oneshotkeysandrightctrl wait-0-200 #oneshotkeysandrightctrl -> +oneshotkeysandrightctrl -oneshotkeysandrightctrl +oneshotkeysandrightctrl oneshot-mode.break-combo-preparation=#{*}-2000 -idle-mode.macro.tts1=+capslock +space -> +f14 -f14 -idle-mode.macro.tts2=+tab +space -> +f15 -f15 -normal-mode.macro.tts1=+capslock +space -> +f14 -f14 -normal-mode.macro.tts2=+tab +space -> +f15 -f15 -typing-mode.macro.tts1=+capslock +space -> +f14 -f14 -typing-mode.macro.tts2=+tab +space -> +f15 -f15 \ No newline at end of file +idle-mode.macro.tts1=+capslock-0-200 +space -> +f14 -f14 +idle-mode.macro.tts2=+tab-0-200 +space -> +f15 -f15 +normal-mode.macro.tts1=+capslock-0-200 +space -> +f14 -f14 +normal-mode.macro.tts2=+tab-0-200 +space -> +f15 -f15 +typing-mode.macro.tts1=+capslock-0-200 +space -> +f14 -f14 +typing-mode.macro.tts2=+tab-0-200 +space -> +f15 -f15 + +# Recursive hint grid (neru-style): press f10, then drill down with a 3x3 grid +# (r t y / f g h / v b n). Each layer narrows to the selected cell. Backspace +# goes up a level, esc exits, the last layer lands the mouse in normal-mode. +#key-alias.recgridkey.uk-qwerty=r t y f g h v b n +#key-alias.recgridkey.uk-qwerty=u i o j k l m , . +key-alias.recgridkey.uk-qwerty=u i o j k l m , . + +_recursive-hint-mode.hint.type=grid +_recursive-hint-mode.hint.grid-area=last-selected-hint-cell +_recursive-hint-mode.hint.selection-keys=recgridkey +_recursive-hint-mode.hint.mouse-movement=mouse-follows-hint-grid-center +_recursive-hint-mode.hint.grid-max-row-count=3 +_recursive-hint-mode.hint.grid-max-column-count=3 +_recursive-hint-mode.hint.grid-cell-sizing=fit +# Sub-key preview (neru's sub_key_preview): a 3x3 grid inside each cell, labelled +# with the next level's keys (same recgridkey set) so you can see two moves ahead. +_recursive-hint-mode.hint.subgrid-max-row-count=3 +_recursive-hint-mode.hint.subgrid-max-column-count=3 +_recursive-hint-mode.hint.subgrid-selection-keys=recgridkey +_recursive-hint-mode.hint.subgrid-closed=false +_recursive-hint-mode.hint.subgrid-border-thickness=0 +_recursive-hint-mode.hint.subgrid-border-color=#FFFFFF +_recursive-hint-mode.hint.subgrid-border-opacity=0.25 +_recursive-hint-mode.hint.subgrid-font-color=#FFFFFF +_recursive-hint-mode.hint.subgrid-font-opacity=0.0 +_recursive-hint-mode.hint.subsubgrid-max-row-count=2 +_recursive-hint-mode.hint.subsubgrid-max-column-count=2 +_recursive-hint-mode.to.idle-mode=+esc | #rightctrl +_recursive-hint-mode.noop=+accidentalhintkey +_recursive-hint-mode.to.click-after-hint-mode=+space +_recursive-hint-mode.set-variable.isrecursivehint=+recgridkey + +recursive-hint1-mode=_recursive-hint-mode +recursive-hint1-mode.hint.grid-area=active-screen +recursive-hint1-mode.hint.box-border-thickness=4 +recursive-hint1-mode.hint.font-size=90 +recursive-hint1-mode.hint.subgrid-font-size=30 +recursive-hint1-mode.hint.subsubgrid-border-thickness=4 +recursive-hint1-mode.hint.subsubgrid-border-length=20 +normal-mode.to.recursive-hint1-mode=+u +recursive-hint1-mode.to.idle-mode=+esc | #rightctrl | +hintback +recursive-hint1-mode.to.recursive-hint2-mode=+recgridkey + +recursive-hint2-mode=_recursive-hint-mode +recursive-hint2-mode.hint.box-border-thickness=3 +recursive-hint2-mode.hint.font-size=30 +recursive-hint2-mode.hint.subsubgrid-border-thickness=2 +recursive-hint2-mode.hint.subsubgrid-border-length=10 +recursive-hint2-mode.to.recursive-hint3-mode=+recgridkey +recursive-hint2-mode.to.recursive-hint1-mode=+hintback + +recursive-hint3-mode=_recursive-hint-mode +recursive-hint3-mode.hint.box-border-thickness=2 +recursive-hint3-mode.hint.font-size=14 +recursive-hint3-mode.hint.subgrid-font-size=5 +recursive-hint3-mode.hint.subsubgrid-border-thickness=2 +recursive-hint3-mode.hint.subsubgrid-border-length=2 +recursive-hint3-mode.to.recursive-hint4-mode=+recgridkey +recursive-hint3-mode.to.recursive-hint2-mode=+hintback + +recursive-hint4-mode=_recursive-hint-mode +recursive-hint4-mode.hint.font-size=5 +recursive-hint4-mode.hint.subgrid-border-opacity=0 +recursive-hint4-mode.hint.subgrid-font-opacity=0 +recursive-hint4-mode.hint.subsubgrid-border-thickness=0 +recursive-hint4-mode.to.click-after-hint-mode=+recgridkey | +space +#recursive-hint4-mode.break-combo-preparation=+recgridkey +recursive-hint4-mode.to.recursive-hint3-mode=+hintback \ No newline at end of file diff --git a/configuration/combo-reference.md b/configuration/combo-reference.md index aebe918c..b000f59c 100644 --- a/configuration/combo-reference.md +++ b/configuration/combo-reference.md @@ -483,6 +483,19 @@ _{!isslow !iszoom !isrclick} none of these three variables are set _{iszoom !isnomove} iszoom is set AND isnomove is not set ``` +### Built-in variables + +Some variables are maintained automatically by mousemaster and can be used in preconditions like any other variable. They cannot be set, unset, or cleared from the configuration (attempting to `set-variable`/`unset-variable` a built-in name is a configuration error, and `clear-variables` leaves them untouched). + +| Variable | Set when | +|------------|-------------------------------------------------------------------------------------------------------------------| +| `isidling` | The mouse is idle: not moving, no mouse button pressed, not wheeling, and no combo completed on the current tick. | + +``` +_{isidling} +a combo only works while the mouse is idle +mode.indicator.idle-color=... | _{isidling} -> gray idle-only property value +``` + ### Mode property mutation with variables Properties can have different values depending on which variables are set. The syntax uses `|` to separate branches, with `_{}` variable preconditions and `->` pointing to the value: diff --git a/configuration/configuration-reference.md b/configuration/configuration-reference.md index 482a3071..7d72aeb7 100644 --- a/configuration/configuration-reference.md +++ b/configuration/configuration-reference.md @@ -544,6 +544,7 @@ hint-mode.hint.active-screen-grid-area-center=screen-center # Grid layout configuration hint-mode.hint.grid-cell-width=74 hint-mode.hint.grid-cell-height=36 +hint-mode.hint.grid-cell-sizing=fixed hint-mode.hint.layout-row-count=6 hint-mode.hint.layout-column-count=5 ``` @@ -562,6 +563,10 @@ hint-mode.hint.layout-column-count=5 - `grid-cell-width`: Width of each hint cell in pixels - `grid-cell-height`: Height of each hint cell in pixels +- **`grid-cell-sizing`**: How cell size is determined: + - `fixed` (default): cells are `grid-cell-width` x `grid-cell-height` pixels, and `grid-max-row-count`/`grid-max-column-count` cap how many fit. + - `fit`: cells are sized to fill the area with exactly `grid-max-row-count` x `grid-max-column-count` cells (the pixel sizes are ignored). Useful for a fixed grid shape (e.g. 3x3) that adapts to any screen, and for a recursive grid via `grid-area=last-selected-hint-cell`. + - **Grid arrangement**: Control the number of rows and columns: - `layout-row-count`: Number of rows in the hint grid - `layout-column-count`: Number of columns in the hint grid @@ -644,6 +649,20 @@ hint-mode.hint.box-shadow-vertical-offset=2 hint-mode.hint.box-width-percent=1.0 hint-mode.hint.box-height-percent=1.0 +# Subgrid: a miniature hint grid drawn inside each hint box (a preview of the +# next level). Set subgrid-selection-keys to label the sub-cells. +hint-mode.hint.subgrid-max-row-count=1 +hint-mode.hint.subgrid-max-column-count=1 +hint-mode.hint.subgrid-selection-keys= +hint-mode.hint.subgrid-border-thickness=1 +hint-mode.hint.subgrid-border-length=10000 +hint-mode.hint.subgrid-border-color=#FFFFFF +hint-mode.hint.subgrid-border-opacity=1.0 +hint-mode.hint.subgrid-closed=false +hint-mode.hint.subgrid-font-size=10 +hint-mode.hint.subgrid-font-color=#FFFFFF +hint-mode.hint.subgrid-font-opacity=1.0 + # Cell padding for UI hints and position history hints only hint-mode.hint.cell-horizontal-padding=0 hint-mode.hint.cell-vertical-padding=0 @@ -701,6 +720,13 @@ hint-mode.hint.background-opacity=0 - `background-color`: Background color behind all hint boxes, mostly useful for UI hints (hex, default #000000). - `background-opacity`: Background opacity, mostly useful for UI hints (0-1, default 0 = no background). +- Subgrid: draws a grid of lines inside each hint box + - `subgrid-max-row-count` / `subgrid-max-column-count`: How many rows/columns to divide each box into (default 1 = disabled). + - `subgrid-selection-keys`: Keys used to label the sub-cells (accepts a key-alias; empty = lines only, no labels). Labels are generated like the main grid, so they are 1- or 2-char depending on cell count vs. key count. + - `subgrid-font-size` / `subgrid-font-color` / `subgrid-font-opacity` / `subgrid-font-spacing-percent`: Font of the sub-cell labels. + - `subgrid-border-length`: Length of each line; high values (default 10000) draw continuous lines, low values draw short marks (e.g. a `+` at the center of a 2x2 subgrid). + - `subgrid-closed`: Whether the subgrid draws its own outer perimeter (default false). When false, only interior lines are drawn (the hint box border acts as the outer edge); set true for a fully-enclosed grid that does not rely on the parent border. + - Font appearance: controls how hint labels appear - `font-spacing-percent`: Controls character spacing (0=touching, 1=evenly distributed, 0.5=minimal spacing with alignment) @@ -815,6 +841,15 @@ grid-mode.grid.column-count=2 grid-mode.grid.line-visible=true grid-mode.grid.line-color=#FF0000 grid-mode.grid.line-thickness=1 +grid-mode.grid.line-opacity=1.0 +grid-mode.grid.background-color=#FF0000 +grid-mode.grid.background-opacity=0.1 + +# Grid transition and fade animations +grid-mode.grid.transition-animation-enabled=true +grid-mode.grid.transition-animation-duration-millis=100 +grid-mode.grid.fade-animation-enabled=true +grid-mode.grid.fade-animation-duration-millis=100 ``` - **`grid-area`**: Determines where the grid is displayed: @@ -833,6 +868,17 @@ grid-mode.grid.line-thickness=1 - `line-visible`: Whether to show grid lines - `line-color`: Color of grid lines (hex format) - `line-thickness`: Thickness of grid lines in pixels + - `line-opacity`: Opacity of grid lines (0.0 = transparent, 1.0 = opaque). Default 1.0. + - `background-color`: Fill color of the grid area, behind the lines (default #FF0000). + - `background-opacity`: Opacity of the background fill (default 0.1). + +- Grid transition animation: eases the grid to its new position and size when it changes (e.g. after `shrink-grid` or `move-grid`): + - `transition-animation-enabled`: Whether to animate grid transitions. Default enabled. + - `transition-animation-duration-millis`: Animation duration in milliseconds. Default 100. + +- Grid fade animation: fades the grid in and out when it appears and disappears: + - `fade-animation-enabled`: Whether to fade the grid in/out. Default enabled. + - `fade-animation-duration-millis`: Fade duration in milliseconds. Default 100. ### Grid positioning and insets diff --git a/pom.xml b/pom.xml index 66f3b630..80ab2f24 100644 --- a/pom.xml +++ b/pom.xml @@ -5,7 +5,7 @@ 4.0.0 mousemaster mousemaster - 88 + 89 mousemaster