diff --git a/flake.lock b/flake.lock
new file mode 100644
index 00000000..4573cb3a
--- /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": 1783389287,
+ "narHash": "sha256-0xIy4dVLqq47rA+mRy0hXDfjhQd4E5PoIns/RmB7nR4=",
+ "owner": "nixos",
+ "repo": "nixpkgs",
+ "rev": "0ad6f47ea4fe188f4bc8f0380f93ae8523337c6c",
+ "type": "github"
+ },
+ "original": {
+ "owner": "nixos",
+ "ref": "nixos-26.05",
+ "repo": "nixpkgs",
+ "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-qt68": "nixpkgs-qt68"
+ }
+ },
+ "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..1a1f010f
--- /dev/null
+++ b/flake.nix
@@ -0,0 +1,80 @@
+{
+ description = "Development environment for Mousemaster - Linux port";
+
+ 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, 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; [
+ # Java development
+ jdk21
+ maven
+
+ # X11 libs loaded by JNA at runtime
+ libx11
+ libxrandr
+ libxtst # XTest extension - for key/mouse injection
+ libxi
+ libxext
+ libxrender
+ libxfixes
+
+ # 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
+ xcbutilkeysyms
+ xcbutilrenderutil
+ xcbutilwm
+ libxkbcommon
+ fontconfig
+ freetype
+ mesa # provides libGL / libEGL
+
+ wayland # libwayland-client.so, for the Wayland virtual-pointer JNA bindings
+ ];
+
+ shellHook = ''
+ export JAVA_HOME="${pkgs.jdk21}"
+ export LD_LIBRARY_PATH="${pkgs68.qt6.qtbase}/lib:${pkgs.lib.makeLibraryPath (with pkgs; [
+ libx11
+ libxrandr
+ libxtst
+ libxi
+ libxext
+ libxrender
+ libxfixes
+ libxcb
+ xcbutil
+ xcbutilimage
+ xcbutilkeysyms
+ xcbutilrenderutil
+ xcbutilwm
+ libxkbcommon
+ 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"
+ echo "Java: $(java -version 2>&1 | head -n1)"
+ echo "Maven: $(mvn -version 2>&1 | head -n1)"
+ '';
+ };
+ });
+}
diff --git a/mvnw b/mvnw
old mode 100644
new mode 100755
diff --git a/pom.xml b/pom.xml
index 0bb0b844..80ab2f24 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
+
+ Linux
+
+
+ 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/ComboWatcher.java b/src/main/java/mousemaster/ComboWatcher.java
index e748a063..059f5471 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;
@@ -160,17 +163,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);
@@ -186,17 +184,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) {
@@ -489,9 +494,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;
@@ -601,8 +606,10 @@ public PressKeyEventProcessingSet keyEvent(KeyEvent event) {
if (isIgnoredByLeadingWait)
processing = PressKeyEventProcessing.ignoredByLeadingWait(leadingWaitEatsEvents);
else // isComboPreconditionKey must be true
+ // Eaten speculatively; regurgitated on release if no combo ends up
+ // using it (see KeyboardManager's eatenKeys handling).
processing = isPressedComboPreconditionKey ?
- PressKeyEventProcessing.partOfPressedComboPreconditionOnly() :
+ PressKeyEventProcessing.partOfPressedComboPreconditionOnly(true) :
PressKeyEventProcessing.partOfUnpressedComboPreconditionOnly();
processingSet = new PressKeyEventProcessingSet(
new HashMap<>(Map.of(PressKeyEventProcessingSet.dummyCombo, processing)),
@@ -852,8 +859,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);
}
@@ -1405,6 +1412,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/GridManager.java b/src/main/java/mousemaster/GridManager.java
index f5bcf9d3..8cb6a7af 100644
--- a/src/main/java/mousemaster/GridManager.java
+++ b/src/main/java/mousemaster/GridManager.java
@@ -107,10 +107,10 @@ private void moveGrid(int deltaX, int deltaY) {
// We want the grid center in screen.
Screen activeScreen = screenManager.activeScreen();
gridCenterX = Math.max(activeScreen.rectangle().x(), Math.min(
- activeScreen.rectangle().x() + activeScreen.rectangle().width(),
+ activeScreen.rectangle().x() + activeScreen.rectangle().width() - 1,
gridCenterX));
gridCenterY = Math.max(activeScreen.rectangle().y(), Math.min(
- activeScreen.rectangle().y() + activeScreen.rectangle().height(),
+ activeScreen.rectangle().y() + activeScreen.rectangle().height() - 1,
gridCenterY));
x = gridCenterX - grid.width() / 2;
y = gridCenterY - grid.height() / 2;
@@ -188,10 +188,10 @@ private void snap(boolean horizontal, boolean forward) {
if (screenManager.screenContaining(x, y) == null) {
Screen activeScreen = screenManager.activeScreen();
x = Math.max(activeScreen.rectangle().x(), Math.min(
- activeScreen.rectangle().x() + activeScreen.rectangle().width(),
+ activeScreen.rectangle().x() + activeScreen.rectangle().width() - 1,
x));
y = Math.max(activeScreen.rectangle().y(), Math.min(
- activeScreen.rectangle().y() + activeScreen.rectangle().height(),
+ activeScreen.rectangle().y() + activeScreen.rectangle().height() - 1,
y));
}
moveMouseTo(x, y);
@@ -202,7 +202,7 @@ private int mouseColumnX(int mouseColumn, int cellWidth) {
if (mouseColumn <= 0)
x = grid.x();
else if (mouseColumn >= grid.columnCount())
- x = grid.x() + grid.width();
+ x = grid.x() + grid.width() - 1;
else
x = grid.x() + mouseColumn * cellWidth;
return x;
@@ -213,7 +213,7 @@ private int mouseRowY(int mouseRow, int cellHeight) {
if (mouseRow <= 0)
y = grid.y();
else if (mouseRow >= grid.rowCount())
- y = grid.y() + grid.height();
+ y = grid.y() + grid.height() - 1;
else
y = grid.y() + mouseRow * cellHeight;
return y;
diff --git a/src/main/java/mousemaster/KeyboardManager.java b/src/main/java/mousemaster/KeyboardManager.java
index 1d061f86..3ecea279 100644
--- a/src/main/java/mousemaster/KeyboardManager.java
+++ b/src/main/java/mousemaster/KeyboardManager.java
@@ -173,9 +173,16 @@ else if (processingSet.isPartOfComboSequence()) {
}
boolean mustBeEaten = processingSet.mustBeEaten() ||
macroPlayer.isKeyPressedByMacro(key);
+ // Forward before keyPressedNotEaten so regurgitated keys aren't delivered
+ // out of order (keyPressedNotEaten writes to uinput synchronously on Linux).
+ // TODO: shared code. Consider how this affects Windows. Is this the correct
+ // location for this solution? Is this the correct solution?
+ for (Regurgitate regurgitate : regurgitates) {
+ keyRegurgitator.regurgitate(regurgitate, !regurgitate.alsoRelease());
+ }
if (!mustBeEaten)
macroPlayer.keyPressedNotEaten(key);
- return eatAndRegurgitates(mustBeEaten, regurgitates);
+ return eatAndRegurgitates(mustBeEaten, List.of());
}
else { // Key release.
if (processingSet != null) {
@@ -263,7 +270,12 @@ else if (processingSet.isPartOfComboSequence()) {
eatenKeys.put(key, new Eat(true, existingEat.processingSet()));
}
}
- if (!mustBeEaten)
+ // 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)
macroPlayer.keyReleasedNotEaten(key);
return eatAndRegurgitates(mustBeEaten, regurgitates);
}
@@ -281,6 +293,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;
@@ -325,6 +340,28 @@ private boolean markOtherKeysOfTheseCombosAsCompleted(List comple
processing.mustBeEaten(), true,
processing.isComboPreparationBreaker() || forceIsComboPreparationBreaker));
}
+ // 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) {
+ 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 +387,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 +402,31 @@ private List buildRegurgitates(Key filterKey, Key releasingKey,
return regurgitates;
}
+ /**
+ * 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) {
+ 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(
@@ -480,6 +537,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()
@@ -516,9 +579,21 @@ 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));
+ }
+
+ /**
+ * 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())
+ return !p.mustBeEaten();
+ return !p.isPartOfComboSequence() ||
+ p.isPartOfCompletedComboSequence() ||
+ !p.mustBeEaten();
}
}
diff --git a/src/main/java/mousemaster/MouseManager.java b/src/main/java/mousemaster/MouseManager.java
index 063a9e5c..385bd0b0 100644
--- a/src/main/java/mousemaster/MouseManager.java
+++ b/src/main/java/mousemaster/MouseManager.java
@@ -41,6 +41,10 @@ public class MouseManager implements ModeListener, MousePositionListener {
private boolean lastWheelXActive, lastWheelXForward;
private boolean lastWheelYActive, lastWheelYForward;
+ // Grace period past a jump's expected duration before giving up on it, so a jump
+ // whose target the platform never confirms can't stall mouse input forever.
+ private static final double JUMP_ARRIVAL_TIMEOUT_SECONDS = 0.5;
+
private int mouseX, mouseY;
private boolean jumping;
private double jumpDuration;
@@ -190,32 +194,39 @@ else if (!jumping)
double jumpTotalDuration =
Math.hypot(jumpEndX - jumpBeginX, jumpEndY - jumpBeginY) /
jumpVelocity;
- double percent = Math.min(1, jumpDuration / jumpTotalDuration);
- percent = new Easing.Smootherstep().apply(percent);
- int nextJumpX = (int) (jumpBeginX + (jumpEndX - jumpBeginX) * percent);
- int nextJumpY = (int) (jumpBeginY + (jumpEndY - jumpBeginY) * percent);
- // Merge the user movement in.
- if (percent != 1) {
- if (jumpX == jumpEndX) {
- int movingDeltaX = xMoveForwardStack.isEmpty() ? 0 :
- (int) (deltaDistanceX * (xMoveForwardStack.peek() ? 1 : -1));
- deltaDistanceX -= (int) deltaDistanceX;
- nextJumpX += movingDeltaX;
- jumpEndX += movingDeltaX;
+ if (jumpDuration > jumpTotalDuration + JUMP_ARRIVAL_TIMEOUT_SECONDS) {
+ // Timed out waiting for mouseMoved() to confirm arrival - give up.
+ jumping = false;
+ jumpDuration = 0;
+ }
+ else {
+ double percent = Math.min(1, jumpDuration / jumpTotalDuration);
+ percent = new Easing.Smootherstep().apply(percent);
+ int nextJumpX = (int) (jumpBeginX + (jumpEndX - jumpBeginX) * percent);
+ int nextJumpY = (int) (jumpBeginY + (jumpEndY - jumpBeginY) * percent);
+ // Merge the user movement in.
+ if (percent != 1) {
+ if (jumpX == jumpEndX) {
+ int movingDeltaX = xMoveForwardStack.isEmpty() ? 0 :
+ (int) (deltaDistanceX * (xMoveForwardStack.peek() ? 1 : -1));
+ deltaDistanceX -= (int) deltaDistanceX;
+ nextJumpX += movingDeltaX;
+ jumpEndX += movingDeltaX;
+ }
+ if (jumpY == jumpEndY) {
+ int movingDeltaY = yMoveForwardStack.isEmpty() ? 0 :
+ (int) (deltaDistanceY * (yMoveForwardStack.peek() ? 1 : -1));
+ deltaDistanceY -= (int) deltaDistanceY;
+ nextJumpY += movingDeltaY;
+ jumpEndY += movingDeltaY;
+ }
}
- if (jumpY == jumpEndY) {
- int movingDeltaY = yMoveForwardStack.isEmpty() ? 0 :
- (int) (deltaDistanceY * (yMoveForwardStack.peek() ? 1 : -1));
- deltaDistanceY -= (int) deltaDistanceY;
- nextJumpY += movingDeltaY;
- jumpEndY += movingDeltaY;
+ if (nextJumpX != jumpX || nextJumpY != jumpY) {
+ mouseController.synchronousMoveTo(nextJumpX, nextJumpY);
+ jumpX = nextJumpX;
+ jumpY = nextJumpY;
}
}
- if (nextJumpX != jumpX || nextJumpY != jumpY) {
- mouseController.synchronousMoveTo(nextJumpX, nextJumpY);
- jumpX = nextJumpX;
- jumpY = nextJumpY;
- }
}
if (activelyWheeling()) {
wheelDuration += delta;
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/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/PressKeyEventProcessing.java b/src/main/java/mousemaster/PressKeyEventProcessing.java
index d2b8d026..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,
@@ -8,6 +11,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 +29,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 +45,16 @@ 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;
}
+ // 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 ||
@@ -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/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
}
}
}
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/LibUinput.java b/src/main/java/mousemaster/platform/linux/LibUinput.java
new file mode 100644
index 00000000..d2c66430
--- /dev/null
+++ b/src/main/java/mousemaster/platform/linux/LibUinput.java
@@ -0,0 +1,194 @@
+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 = 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)
+
+ // 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 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);
+
+ 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 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) {
+ 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 device destroyed (fd={})", fd);
+ }
+ }
+
+ 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);
+ 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/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/LibX11.java b/src/main/java/mousemaster/platform/linux/LibX11.java
new file mode 100644
index 00000000..8c5933ca
--- /dev/null
+++ b/src/main/java/mousemaster/platform/linux/LibX11.java
@@ -0,0 +1,199 @@
+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);
+
+ // 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);
+
+ // 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 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
+ */
+ @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/LibXShape.java b/src/main/java/mousemaster/platform/linux/LibXShape.java
new file mode 100644
index 00000000..7010ee4f
--- /dev/null
+++ b/src/main/java/mousemaster/platform/linux/LibXShape.java
@@ -0,0 +1,31 @@
+package mousemaster.platform.linux;
+
+import com.sun.jna.Library;
+import com.sun.jna.Native;
+import com.sun.jna.Pointer;
+
+/**
+ * JNA bindings for the X Shape extension (libXext.so), used to make overlay windows
+ * click-through by giving them an empty input shape. Qt's WA_TransparentForMouseEvents
+ * attribute does not reliably achieve this for frameless, X11BypassWindowManagerHint
+ * (override-redirect) windows on every window manager, so the input shape is cleared
+ * directly at the X11 level instead - the same mechanism compositors/overlay tools use.
+ */
+public interface LibXShape extends Library {
+
+ LibXShape INSTANCE = Native.load("Xext", LibXShape.class);
+
+ int ShapeInput = 2;
+ int ShapeSet = 0;
+
+ /**
+ * Combines rectangles is normally used to set a window's shape; passing a null
+ * rectangle list with a count of 0 sets the given shape kind to the empty region -
+ * for ShapeInput, that means the window accepts no mouse/keyboard input anywhere,
+ * so clicks pass through to whatever is behind it.
+ */
+ void XShapeCombineRectangles(Pointer display, long window, int destKind,
+ int xOffset, int yOffset, Pointer rectangles,
+ int rectangleCount, int operation, int ordering);
+
+}
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/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/LinuxEvdev.java b/src/main/java/mousemaster/platform/linux/LinuxEvdev.java
new file mode 100644
index 00000000..f0cc2a94
--- /dev/null
+++ b/src/main/java/mousemaster/platform/linux/LinuxEvdev.java
@@ -0,0 +1,217 @@
+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;
+
+ // linux/input-event-codes.h KEY_Q. Devices that report this are real typing
+ // keyboards; devices that merely have a "kbd" handler but not this bit are things
+ // like ACPI power/lid/brightness button arrays, which must never be grabbed
+ // exclusively (doing so silently swallows e.g. the power button).
+ private static final int KEY_Q = 16;
+
+ 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 final int uinputFd;
+ private volatile boolean running = true;
+
+ public LinuxEvdev(int uinputFd) {
+ this.uinputFd = uinputFd;
+ 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;
+ boolean hasLetterKeys = false;
+ String eventNode = null;
+ for (String line : content.lines().toList()) {
+ if (line.isBlank()) {
+ if (hasKbd && !isVirtual && hasLetterKeys && eventNode != null)
+ result.add("/dev/input/" + eventNode);
+ hasKbd = false;
+ isVirtual = false;
+ hasLetterKeys = 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;
+ }
+ } else if (line.startsWith("B: KEY=")) {
+ hasLetterKeys = hasKeyBit(line.substring("B: KEY=".length()), KEY_Q);
+ }
+ }
+ // Handle last block if file doesn't end with blank line
+ if (hasKbd && !isVirtual && hasLetterKeys && 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;
+ }
+
+ /**
+ * The kernel prints the KEY= capability bitmask as space-separated hex words,
+ * most-significant word first. The last (rightmost) word covers bits 0-63, which is
+ * all we need to test for a representative letter key like KEY_Q.
+ */
+ private static boolean hasKeyBit(String bitmaskLine, int bit) {
+ String[] tokens = bitmaskLine.trim().split("\\s+");
+ if (tokens.length == 0)
+ return false;
+ try {
+ long lastWord = Long.parseUnsignedLong(tokens[tokens.length - 1], 16);
+ return ((lastWord >> bit) & 1L) != 0;
+ } catch (NumberFormatException e) {
+ return false;
+ }
+ }
+
+ 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 {
+ // No Key enum equivalent, so the combo engine can never act on this
+ // code - regurgitate it as-is instead of silently dropping it, since
+ // this device is exclusively grabbed and nothing else will ever see
+ // the event otherwise (e.g. KEY_POWER on an ACPI button-array device).
+ logger.debug("No Key mapping for evdev code {} on '{}', regurgitating raw",
+ keycode, deviceName);
+ LibUinput.writeInputEvent(uinputFd, LibUinput.EV_KEY, keycode, value);
+ LibUinput.writeInputEvent(uinputFd, LibUinput.EV_SYN, LibUinput.SYN_REPORT, 0);
+ }
+ }
+ }
+ }
+
+ /** 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
new file mode 100644
index 00000000..e99f4c75
--- /dev/null
+++ b/src/main/java/mousemaster/platform/linux/LinuxKeyboard.java
@@ -0,0 +1,84 @@
+package mousemaster.platform.linux;
+
+import mousemaster.Key;
+import mousemaster.MacroMoveDestination;
+import mousemaster.ResolvedKeyMacroMove;
+import mousemaster.ResolvedMacroMove;
+import mousemaster.platform.KeyboardController;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.util.List;
+
+/**
+ * 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) {
+ }
+
+ @Override
+ public void reset() {
+ }
+
+ @Override
+ public void sendInputMoves(List moves, boolean startRepeat) {
+ 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) {
+ emitKey(key, 1);
+ }
+
+ @Override
+ public void keyReleasedNotEaten(Key key) {
+ emitKey(key, 0);
+ }
+
+ @Override
+ public void recordEarlyReleaseForQueuedPress(Key key) {
+ // No send queue on Linux — writes are synchronous, nothing to track
+ }
+
+ @Override
+ public void clearEarlyReleaseForQueuedPress(Key key) {
+ }
+
+ 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
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..6a20a84c
--- /dev/null
+++ b/src/main/java/mousemaster/platform/linux/LinuxMouse.java
@@ -0,0 +1,20 @@
+package mousemaster.platform.linux;
+
+import mousemaster.platform.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 {
+
+ // 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;
+ }
+
+ public Integer lastSyntheticY() {
+ return null;
+ }
+}
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..6e9d44f4
--- /dev/null
+++ b/src/main/java/mousemaster/platform/linux/LinuxOverlay.java
@@ -0,0 +1,450 @@
+package mousemaster.platform.linux;
+
+import com.sun.jna.Pointer;
+import io.qt.core.Qt;
+import io.qt.gui.QPixmap;
+import io.qt.widgets.QApplication;
+import io.qt.widgets.QWidget;
+import mousemaster.*;
+import mousemaster.platform.Overlay;
+import mousemaster.platform.Screens;
+import mousemaster.qt.QtHintFont;
+import mousemaster.qt.ScreenshotWidget;
+import mousemaster.qt.TransparentWindow;
+import mousemaster.renderer.GridRenderer;
+import mousemaster.renderer.HintMeshRenderer;
+import mousemaster.renderer.IndicatorRenderer;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+import java.util.Set;
+
+/**
+ * Linux overlay implementation. Delegates rendering to the same shared, platform-agnostic
+ * renderer classes Windows uses (GridRenderer, HintMeshRenderer, IndicatorRenderer,
+ * ScreenshotWidget); this class only handles X11-specific window management (stacking,
+ * and the one-tick-deferred screenshot capture zoom uses, since Linux has no
+ * capture-exclusion API like Windows' WDA_EXCLUDEFROMCAPTURE) plus the small glue the
+ * renderers need (mouse position, active screen, screen enumeration).
+ */
+public class LinuxOverlay implements Overlay {
+
+ private static final Logger logger = LoggerFactory.getLogger(LinuxOverlay.class);
+
+ /** No XFixesGetCursorImage-based real cursor size lookup yet; assume a fixed size. */
+ private static final int DEFAULT_CURSOR_SIZE = 24;
+
+ private final Pointer display;
+ private final Screens screens;
+ private final ScreenManager screenManager;
+ private final LinuxPlatform platform;
+ private Runnable messagePump;
+
+ private GridRenderer gridRenderer;
+ /** Owns no QWidget, so it can be created eagerly (no QtJambi native-load ordering). */
+ private final HintMeshRenderer hintMeshRenderer;
+ private IndicatorRenderer indicatorRenderer;
+ private ScreenshotWidget screenshotWidget;
+ private Zoom currentZoom;
+ private boolean waitForZoom;
+ private boolean zoomAfterHintMeshEndAnimation;
+ private Zoom afterHintMeshEndAnimationZoom;
+
+ private Rectangle pendingCaptureRect;
+ private Zoom pendingCaptureZoom;
+ private boolean pendingCaptureGridWasVisible;
+ private boolean pendingCaptureHintWasVisible;
+
+ /** Windows awaiting a click-through shape once their real geometry is set (see applyX11OverlayFlags). */
+ private final List pendingClickThroughWidgets = new ArrayList<>();
+
+ public LinuxOverlay(Pointer display, Screens screens, LinuxPlatform platform) {
+ this.display = display;
+ this.screens = screens;
+ this.screenManager = new ScreenManager(screens);
+ this.platform = platform;
+ hintMeshRenderer = new HintMeshRenderer(this::createStyledHintMeshWindow,
+ this::hintMeshEndAnimationEndedCallback);
+ }
+
+ /** The window factory the renderer uses: a styled, transparent, click-through window. */
+ private TransparentWindow createStyledHintMeshWindow() {
+ TransparentWindow window = new TransparentWindow();
+ applyX11OverlayFlags(window);
+ return window;
+ }
+
+ /** Runs when the hint container end-animation finishes: hides the hint mesh, then
+ * applies any zoom that was deferred until the animation finished. */
+ private void hintMeshEndAnimationEndedCallback() {
+ hideHintMesh();
+ if (zoomAfterHintMeshEndAnimation) {
+ zoomAfterHintMeshEndAnimation = false;
+ Zoom zoom = afterHintMeshEndAnimationZoom;
+ afterHintMeshEndAnimationZoom = null;
+ setZoom(zoom);
+ }
+ }
+
+ @Override
+ public void update(double delta) {
+ hintMeshRenderer.runPendingWork();
+ 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)
+ gridRenderer.widget().show();
+ if (pendingCaptureHintWasVisible)
+ for (TransparentWindow window : hintMeshRenderer.windows())
+ window.show();
+ if (screenshotWidget == null)
+ createScreenshotWindow();
+ screenshotWidget.move(pendingCaptureRect.x(), pendingCaptureRect.y());
+ screenshotWidget.resize(pendingCaptureRect.width(), pendingCaptureRect.height());
+ screenshotWidget.setScreenshot(pixmap, pendingCaptureRect);
+ screenshotWidget.setZoom(pendingCaptureZoom);
+ currentZoom = pendingCaptureZoom;
+ screenshotWidget.show();
+ screenshotWidget.repaint();
+ setTopmost();
+ pendingCaptureRect = null;
+ pendingCaptureZoom = null;
+ }
+
+ @Override
+ public void flushCache() {
+ hintMeshRenderer.flushCache();
+ }
+
+ @Override
+ public void setTopmost() {
+ applyPendingClickThrough();
+ // Mirrors WindowsOverlay's z-order (topmost first: grid, then hint windows,
+ // then indicator, then the zoom/screenshot backdrop at the back). Qt's raise()
+ // brings a top-level window to the front of the stack, so raise in the reverse
+ // order here - whichever should end up topmost is raised last.
+ if (screenshotWidget != null)
+ screenshotWidget.raise();
+ if (indicatorRenderer != null && indicatorRenderer.showing())
+ indicatorRenderer.window().raise();
+ for (TransparentWindow window : hintMeshRenderer.windows())
+ window.raise();
+ if (gridRenderer != null)
+ gridRenderer.widget().raise();
+ }
+
+ @Override
+ public void setMessagePump(Runnable pump) {
+ this.messagePump = pump;
+ hintMeshRenderer.setMessagePump(pump);
+ }
+
+ /**
+ * Pre-warms the font engine with all hint fonts from the configuration, shifting
+ * the cost of first-use font metrics computation away from the first hint render.
+ */
+ @Override
+ public void preWarmFontStyles(Set configs) {
+ QtHintFont.preWarm(configs);
+ }
+
+ @Override
+ public void preWarmHintMeshWindows() {
+ hintMeshRenderer.preWarmHintMeshWindows(screens.findScreens());
+ }
+
+ @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) {
+ Objects.requireNonNull(indicator);
+ if (indicatorRenderer == null) {
+ indicatorRenderer = new IndicatorRenderer();
+ applyX11OverlayFlags(indicatorRenderer.window());
+ }
+ indicatorRenderer.setIndicator(indicator, fadeAnimationEnabled,
+ fadeAnimationDuration, allowFade, mouseRectangle(), cursorVisualCenter(),
+ activeScreen(), currentZoom);
+ setTopmost();
+ }
+
+ @Override
+ public void hideIndicator(boolean allowFade) {
+ if (indicatorRenderer != null)
+ indicatorRenderer.hide(allowFade);
+ }
+
+ /** Repositions the visible indicator when the mouse moves. Windows does this via its
+ * low-level hook calling WindowsOverlay.mouseMoved(); Linux has no such hook, so
+ * LinuxPlatform's XQueryPointer-based polling calls this directly instead. */
+ void mouseMoved(int x, int y) {
+ if (indicatorRenderer == null || !indicatorRenderer.showing())
+ return;
+ // During zoom, currentZoom may still reflect the previous frame's center; the
+ // active zoom manager corrects it via updateScreenshotZoom before the next
+ // repaint, so skip here to avoid a stale-center mispositioning (mirrors
+ // WindowsOverlay.mouseMoved's same early-return during an active zoom).
+ if (currentZoom != null)
+ return;
+ indicatorRenderer.reposition(new Rectangle(x, y, DEFAULT_CURSOR_SIZE,
+ DEFAULT_CURSOR_SIZE), new Point(0, 0),
+ screenManager.nearestScreenContaining(x, y), null);
+ }
+
+ private int mouseX() {
+ Integer x = platform.lastMouseX();
+ return x != null ? x : 0;
+ }
+
+ private int mouseY() {
+ Integer y = platform.lastMouseY();
+ return y != null ? y : 0;
+ }
+
+ private Rectangle mouseRectangle() {
+ return new Rectangle(mouseX(), mouseY(), DEFAULT_CURSOR_SIZE, DEFAULT_CURSOR_SIZE);
+ }
+
+ private Point cursorVisualCenter() {
+ // Linux has no per-cursor-bitmap hotspot lookup (unlike WindowsMouseController's
+ // GetIconInfo-based computeCursorVisualCenter); mirror Windows' own
+ // fallback-when-lookup-fails value of (0, 0) rather than the cursor's actual
+ // visual center relative to its hotspot.
+ return new Point(0, 0);
+ }
+
+ private Screen activeScreen() {
+ return screenManager.nearestScreenContaining(mouseX(), mouseY());
+ }
+
+ @Override
+ public void setGrid(Grid grid) {
+ Objects.requireNonNull(grid);
+ boolean firstCreation = gridRenderer == null;
+ if (firstCreation) {
+ gridRenderer = new GridRenderer();
+ applyX11OverlayFlags(gridRenderer.widget());
+ }
+ gridRenderer.setGrid(grid, virtualDesktopBounds(),
+ (int) Math.round(grid.lineThickness()));
+ setTopmost();
+ }
+
+ @Override
+ public void hideGrid() {
+ if (gridRenderer != null)
+ gridRenderer.hide();
+ }
+
+ @Override
+ public void setHintMesh(HintMesh hintMesh, Zoom zoom) {
+ setHintMesh(hintMesh, zoom, false);
+ }
+
+ @Override
+ public void setHintMesh(HintMesh hintMesh, Zoom zoom, boolean hintMatch) {
+ boolean nonMatchShown = hintMeshRenderer.setHintMesh(hintMesh, zoom, hintMatch,
+ screens.findScreens());
+ if (nonMatchShown && zoomAfterHintMeshEndAnimation) {
+ zoomAfterHintMeshEndAnimation = false;
+ Zoom deferredZoom = afterHintMeshEndAnimationZoom;
+ afterHintMeshEndAnimationZoom = null;
+ setZoom(deferredZoom);
+ }
+ setTopmost();
+ }
+
+ @Override
+ public void hideHintMesh() {
+ hintMeshRenderer.hideHintMesh();
+ }
+
+ @Override
+ public void animateHintMatch(Hint hint) {
+ hintMeshRenderer.animateHintMatch(hint, screens.findScreens());
+ }
+
+ @Override
+ public void setZoom(Zoom zoom) {
+ if (currentZoom != null && currentZoom.equals(zoom))
+ return;
+ if (hintMeshRenderer.isHintMeshEndAnimation()) {
+ if (!zoomAfterHintMeshEndAnimation) {
+ zoomAfterHintMeshEndAnimation = true;
+ afterHintMeshEndAnimationZoom = zoom;
+ return;
+ }
+ else {
+ // We skip the enqueued zoom.
+ zoomAfterHintMeshEndAnimation = false;
+ afterHintMeshEndAnimationZoom = null;
+ }
+ }
+ if (zoom == null) {
+ cancelPendingCapture();
+ currentZoom = null;
+ if (screenshotWidget != null) {
+ screenshotWidget.setZoom(null);
+ screenshotWidget.hide();
+ }
+ return;
+ }
+ requestScreenshotCapture(zoom.screenRectangle(), zoom);
+ }
+
+ @Override
+ public void startScreenshotZoomAnimation(Rectangle screenRect, Zoom beginZoom) {
+ requestScreenshotCapture(screenRect, beginZoom);
+ }
+
+ @Override
+ public void updateScreenshotZoom(Zoom zoom) {
+ currentZoom = zoom;
+ if (screenshotWidget == null)
+ return;
+ screenshotWidget.setZoom(zoom);
+ screenshotWidget.repaint();
+ mouseMoved(mouseX(), mouseY());
+ setTopmost();
+ }
+
+ @Override
+ public void endScreenshotZoomAnimation(Zoom finalZoom) {
+ if (screenshotWidget == null)
+ return;
+ if (finalZoom == null) {
+ currentZoom = null;
+ screenshotWidget.setZoom(null);
+ screenshotWidget.hide();
+ return;
+ }
+ currentZoom = finalZoom;
+ screenshotWidget.setZoom(finalZoom);
+ screenshotWidget.repaint();
+ setTopmost();
+ }
+
+ /**
+ * 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 = gridRenderer != null && gridRenderer.showing();
+ pendingCaptureHintWasVisible = hintMeshRenderer.showing();
+ if (pendingCaptureGridWasVisible)
+ gridRenderer.widget().hide();
+ if (pendingCaptureHintWasVisible)
+ for (TransparentWindow window : hintMeshRenderer.windows())
+ window.hide();
+ if (screenshotWidget != null)
+ screenshotWidget.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)
+ gridRenderer.widget().show();
+ if (pendingCaptureHintWasVisible)
+ for (TransparentWindow window : hintMeshRenderer.windows())
+ window.show();
+ pendingCaptureRect = null;
+ pendingCaptureZoom = null;
+ }
+
+ private void createScreenshotWindow() {
+ screenshotWidget = new ScreenshotWidget();
+ applyX11OverlayFlags(screenshotWidget);
+ }
+
+ /**
+ * Applies the X11-specific window flags/attributes every overlay window needs:
+ * topmost, bypass-the-window-manager, and click-through. Called on all four window
+ * types (indicator, hint-mesh, grid, screenshot) - GridRenderer's internal widget
+ * and ScreenshotWidget are shared with Windows and only set FramelessWindowHint
+ * themselves (Windows applies its own native WS_EX_* equivalents after construction
+ * instead), and even TransparentWindow-based windows need the real X11 shape call
+ * below, not just the Qt-level attribute set in its constructor.
+ */
+ private void applyX11OverlayFlags(QWidget widget) {
+ widget.setWindowFlags(Qt.WindowType.FramelessWindowHint,
+ Qt.WindowType.X11BypassWindowManagerHint,
+ Qt.WindowType.WindowStaysOnTopHint);
+ widget.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents);
+ // Deferred to setTopmost(): makeClickThrough() forces the native X11 window to
+ // be created via winId(), and at this point the widget still has its default
+ // pre-resize size. Applying it here would create the window at the wrong size.
+ pendingClickThroughWidgets.add(widget);
+ }
+
+ private void applyPendingClickThrough() {
+ for (QWidget widget : pendingClickThroughWidgets)
+ makeClickThrough(widget);
+ pendingClickThroughWidgets.clear();
+ }
+
+ /**
+ * Clears the window's X11 input shape so every click passes through to whatever is
+ * behind it - WA_TransparentForMouseEvents alone isn't enough for frameless,
+ * override-redirect windows on every window manager.
+ */
+ private void makeClickThrough(QWidget widget) {
+ LibXShape.INSTANCE.XShapeCombineRectangles(display, widget.winId(),
+ LibXShape.ShapeInput, 0, 0, Pointer.NULL, 0, LibXShape.ShapeSet, 0);
+ }
+
+ private Rectangle virtualDesktopBounds() {
+ int minX = Integer.MAX_VALUE, minY = Integer.MAX_VALUE;
+ int maxX = Integer.MIN_VALUE, maxY = Integer.MIN_VALUE;
+ for (Screen screen : screens.findScreens()) {
+ 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);
+ }
+
+ @Override
+ public boolean waitForZoomBeforeRepainting() {
+ return waitForZoom;
+ }
+
+ @Override
+ public void setWaitForZoomBeforeRepainting(boolean value) {
+ this.waitForZoom = 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..f9a1f2e1
--- /dev/null
+++ b/src/main/java/mousemaster/platform/linux/LinuxPlatform.java
@@ -0,0 +1,277 @@
+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;
+import org.slf4j.LoggerFactory;
+
+import java.time.Instant;
+import java.util.List;
+
+/**
+ * Linux platform implementation. Wires together evdev input capture, uinput passthrough,
+ * X11 mouse control, and Qt overlay rendering.
+ */
+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 final LinuxEvdev evdev;
+ private KeyboardLayout activeKeyboardLayout;
+ private MouseManager mouseManager;
+ private KeyboardManager keyboardManager;
+ private List mousePositionListeners;
+ private ModeMap modeMap;
+ private long rootWindow;
+ private final boolean isWayland;
+ private Integer lastMouseX;
+ private Integer lastMouseY;
+ private double timeSinceLastSetTopmost;
+ private static final double SET_TOPMOST_INTERVAL_SECONDS = 0.2;
+
+ 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;
+
+ // 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();
+ int uinputKeyboardFd = LibUinput.createKeyboardDevice();
+ keyboard = new LinuxKeyboard(uinputKeyboardFd);
+ screens = new LinuxScreens(display);
+ overlay = new LinuxOverlay(display, screens, this);
+ uiAutomation = new LinuxUiAutomation();
+ activeAppFinder = new LinuxActiveAppFinder();
+ console = new LinuxConsole();
+ keyRegurgitator = new KeyRegurgitator(keyboard);
+
+ // rootWindow must be obtained before creating X11Mouse (mouse needs it for XWarpPointer)
+ rootWindow = LibX11.INSTANCE.XDefaultRootWindow(display);
+ logger.info("Root window handle: {}", rootWindow);
+
+ mouse = isWayland ? new WaylandMouse(screens) : new X11Mouse(display, rootWindow);
+ evdev = new LinuxEvdev(uinputKeyboardFd);
+
+ logger.info("LinuxPlatform initialized successfully");
+
+ // 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);
+ // Periodically re-assert overlay window stacking, in case a tiling WM (e.g.
+ // Hyprland) or another always-on-top app didn't fully respect
+ // WindowStaysOnTopHint - mirrors WindowsPlatform's own periodic setTopmost().
+ timeSinceLastSetTopmost += delta;
+ if (timeSinceLastSetTopmost >= SET_TOPMOST_INTERVAL_SECONDS) {
+ timeSinceLastSetTopmost = 0;
+ overlay.setTopmost();
+ }
+ }
+
+ @Override
+ public void pumpEvents() {
+ KeyEvent event = evdev.pollEvent();
+ while (event != null) {
+ logger.debug("evdev: {}", event);
+ if (keyboardManager != null)
+ handleKeyEvent(event);
+ event = evdev.pollEvent();
+ }
+ }
+
+ /**
+ * 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);
+ 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, 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() {
+ 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)
+ return;
+ lastMouseX = x;
+ lastMouseY = y;
+ for (MousePositionListener listener : mousePositionListeners) {
+ listener.mouseMoved(x, y);
+ }
+ // Windows notifies its overlay's indicator via the low-level mouse hook
+ // (WindowsOverlay.mouseMoved()); X11 has no such hook, so pass the polled
+ // position along the same way as for the listeners above.
+ overlay.mouseMoved(x, y);
+ }
+
+ /** The most recently polled mouse position, or null before the first poll. */
+ Integer lastMouseX() {
+ return lastMouseX;
+ }
+
+ Integer lastMouseY() {
+ return lastMouseY;
+ }
+
+ @Override
+ public void reset(MouseManager mouseManager, KeyboardManager keyboardManager,
+ ModeMap modeMap, List mousePositionListeners,
+ KeyboardLayout activeKeyboardLayout) {
+ this.mouseManager = mouseManager;
+ this.keyboardManager = keyboardManager;
+ this.mousePositionListeners = mousePositionListeners;
+ this.modeMap = modeMap;
+ this.activeKeyboardLayout = activeKeyboardLayout;
+ overlay.setMessagePump(this::pumpEvents);
+ lastMouseX = null;
+ lastMouseY = null;
+ notifyMousePositionListenersIfMoved();
+ }
+
+ @Override
+ public void shutdown() {
+ logger.info("Shutting down LinuxPlatform");
+
+ evdev.destroy();
+ keyboard.destroy();
+ mouse.destroy();
+
+ 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..95b4b4b7
--- /dev/null
+++ b/src/main/java/mousemaster/platform/linux/LinuxScreens.java
@@ -0,0 +1,96 @@
+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);
+
+ // This is a synchronous X server round-trip called on the hot path (every
+ // Mousemaster.run() tick, uncached). If the X server ever stalls, this is the
+ // most likely place the whole single-threaded main loop would hang - log
+ // loudly (WARN, so it shows regardless of configured log level) if it ever
+ // takes unusually long, to pin down a future freeze instead of just seeing a
+ // gap in the log.
+ long callStartNanos = System.nanoTime();
+ Pointer monitorsPtr = LibXRandr.INSTANCE.XRRGetMonitors(display, rootWindow, true, nmonitorsRef);
+ double callDurationSeconds = (System.nanoTime() - callStartNanos) / 1_000_000_000.0;
+ if (callDurationSeconds > 1)
+ logger.warn("XRRGetMonitors took {}s - possible X server stall", callDurationSeconds);
+
+ if (monitorsPtr == null) {
+ logger.warn("XRRGetMonitors returned null - no monitors detected");
+ return screens;
+ }
+
+ int nmonitors = nmonitorsRef.getValue();
+ logger.trace("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.trace("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/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;
+ }
+
+}
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..d5a21723
--- /dev/null
+++ b/src/main/java/mousemaster/platform/linux/WaylandMouse.java
@@ -0,0 +1,348 @@
+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);
+
+ // 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;
+ 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) {
+ 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) {
+ 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) {
+ 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, 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);
+ }
+
+ // ---- 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..9d80b194
--- /dev/null
+++ b/src/main/java/mousemaster/platform/linux/X11Mouse.java
@@ -0,0 +1,156 @@
+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
+
+ // 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;
+ 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;
+ 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);
+ }
+ LibX11.INSTANCE.XFlush(display);
+ }
+
+ @Override
+ public void wheelHorizontallyBy(boolean forward, double delta) {
+ // forward = right = button 7
+ int button = forward ? 7 : 6;
+ 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);
+ }
+ 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);
+ }
+}
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/ScreenshotWidget.java b/src/main/java/mousemaster/qt/ScreenshotWidget.java
index 7d92b8ac..f9a6e5e1 100644
--- a/src/main/java/mousemaster/qt/ScreenshotWidget.java
+++ b/src/main/java/mousemaster/qt/ScreenshotWidget.java
@@ -49,11 +49,31 @@ protected void paintEvent(QPaintEvent event) {
QPainter painter = new QPainter(this);
painter.fillRect(0, 0, width(), height(), new QColor(0, 0, 0));
painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform, true);
- QRectF sourceRect = new QRectF(sourceX, sourceY, sourceWidth, sourceHeight);
- QRectF targetRect = new QRectF(0, 0, width(), height());
- painter.drawPixmap(targetRect, pixmap, sourceRect);
- sourceRect.dispose();
- targetRect.dispose();
+
+ // 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
+ // whatever is behind this widget show through).
+ double clampedSourceX = Math.max(sourceX, 0);
+ double clampedSourceY = Math.max(sourceY, 0);
+ double clampedSourceRight = Math.min(sourceX + sourceWidth, pixmap.width());
+ double clampedSourceBottom = Math.min(sourceY + sourceHeight, pixmap.height());
+ double clampedSourceWidth = Math.max(0, clampedSourceRight - clampedSourceX);
+ double clampedSourceHeight = Math.max(0, clampedSourceBottom - clampedSourceY);
+
+ if (clampedSourceWidth > 0 && clampedSourceHeight > 0) {
+ double targetX = (clampedSourceX - sourceX) * zoomPercent;
+ double targetY = (clampedSourceY - sourceY) * zoomPercent;
+ QRectF sourceRect = new QRectF(clampedSourceX, clampedSourceY,
+ clampedSourceWidth, clampedSourceHeight);
+ QRectF targetRect = new QRectF(targetX, targetY,
+ clampedSourceWidth * zoomPercent, clampedSourceHeight * zoomPercent);
+ painter.drawPixmap(targetRect, pixmap, sourceRect);
+ sourceRect.dispose();
+ targetRect.dispose();
+ }
painter.end();
painter.dispose();
}
diff --git a/src/main/java/mousemaster/qt/TransparentWindow.java b/src/main/java/mousemaster/qt/TransparentWindow.java
index 4a1d7b38..3ade9e1a 100644
--- a/src/main/java/mousemaster/qt/TransparentWindow.java
+++ b/src/main/java/mousemaster/qt/TransparentWindow.java
@@ -14,9 +14,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);
+ // Click-through: the shared renderers (GridRenderer/HintMeshRenderer) never
+ // actually unmap their windows on hide(), only clear the drawn content and rely
+ // on this being permanently click-through - Windows gets the same behavior via
+ // WS_EX_TRANSPARENT applied once at HWND creation (see WindowsOverlay). Without
+ // this, a hidden-but-still-mapped, full-desktop/full-screen overlay window
+ // silently swallows every click underneath it, forever, from first use onward.
+ setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents);
}
public void setBackground(QColor color, QRect rect) {