hyprland: add focus follows mouse toggle (#3755)

This commit is contained in:
Perdixky
2026-10-12 02:15:46 -04:00
committed by GitHub
parent 132dca18ae
commit 80ca0f0ee5
7 changed files with 120 additions and 2 deletions
+1
View File
@@ -245,6 +245,7 @@ Singleton {
property int mangoLayoutGapsOutOverride: Spec.SPEC.mangoLayoutGapsOutOverride.def
property int mangoLayoutRadiusOverride: Spec.SPEC.mangoLayoutRadiusOverride.def
property int mangoLayoutBorderSize: Spec.SPEC.mangoLayoutBorderSize.def
property int hyprlandFollowMouse: Spec.SPEC.hyprlandFollowMouse.def
property string mouseAccelProfile: Spec.SPEC.mouseAccelProfile.def
property real mouseAccelSpeed: Spec.SPEC.mouseAccelSpeed.def
property bool mouseLeftHanded: Spec.SPEC.mouseLeftHanded.def
@@ -315,6 +315,10 @@ var LOCAL_SPEC = {
def: -1,
onChange: "updateCompositorLayout"
},
hyprlandFollowMouse: {
def: -1,
onChange: "updateCompositorInput"
},
mouseAccelProfile: {
def: "default",
onChange: "updateCompositorInput"
@@ -32,6 +32,18 @@ Item {
settingKey: "mouseSettings"
iconName: "mouse"
SettingsToggleRow {
visible: CompositorService.isHyprland && HyprlandService.hyprSupports("input:follow_mouse")
enabled: inputInclude.included && !inputInclude.readOnly && HyprlandService.luaConfigActive && HyprlandService.followMouseMode >= 0
tags: ["mouse", "focus", "follow", "pointer", "window", "hyprland"]
settingKey: "hyprlandFollowMouse"
resetKeys: ["hyprlandFollowMouse"]
text: I18n.tr("Focus follows mouse", "Hyprland toggle that focuses a window when the pointer moves over it")
description: I18n.tr("Focus windows when the pointer moves over them.", "Description of the Hyprland focus follows mouse toggle")
checked: (SettingsData.hyprlandFollowMouse < 0 ? HyprlandService.followMouseMode : SettingsData.hyprlandFollowMouse) === 1
onToggled: checked => SettingsData.set("hyprlandFollowMouse", checked ? 1 : 0)
}
SettingsSliderRow {
tags: ["mouse", "sensitivity", "speed", "accel"]
settingKey: "mouseAccelSpeed"
+31
View File
@@ -42,6 +42,7 @@ Singleton {
property bool hyprOptionsLoading: false
property bool inputGenerationPending: false
property bool inputGenerationRunning: false
property int followMouseMode: -1
// Hyprland only has per-device touchpad speed; probed once per compositor, a hotplugged touchpad waits for the next shell start
property var hyprTouchpadNames: []
property bool hyprDevicesReady: false
@@ -106,6 +107,7 @@ Singleton {
Component.onCompleted: {
if (CompositorService.isHyprland) {
refreshFollowMouse();
refreshWorkspaceRules();
refreshLuaConfigStatus();
if (luaConfigActive)
@@ -144,12 +146,14 @@ Singleton {
target: CompositorService
function onIsHyprlandChanged() {
if (CompositorService.isHyprland) {
refreshFollowMouse();
refreshLuaConfigStatus();
if (luaConfigActive)
ensureDmsLuaConfigs();
generateLayoutConfig();
return;
}
followMouseMode = -1;
luaConfigDetected = false;
luaConfigStatusReady = false;
luaConfigStatusLoading = false;
@@ -219,6 +223,30 @@ Singleton {
return hyprOptionNames[name] === true;
}
function refreshFollowMouse() {
if (!CompositorService.isHyprland)
return;
Proc.runCommand("hypr-follow-mouse", ["hyprctl", "-j", "getoption", "input:follow_mouse"], (output, exitCode) => {
if (exitCode !== 0 || !CompositorService.isHyprland)
return;
try {
const mode = JSON.parse(output).int;
if (Number.isInteger(mode) && mode >= 0 && mode <= 3)
followMouseMode = mode;
} catch (error) {
log.warn("hyprctl follow_mouse parse failed:", error);
}
});
}
Connections {
target: CompositorService.isHyprland ? Hyprland : null
function onRawEvent(event) {
if (event.name === "configreloaded")
root.refreshFollowMouse();
}
}
function loadHyprOptionNames() {
if (!CompositorService.isHyprland || hyprOptionsReady || hyprOptionsLoading)
return;
@@ -819,6 +847,9 @@ hl.layer_rule({
if (hyprSupports(option))
lines.push(line);
};
// -1 leaves existing follow_mouse modes untouched until the user operates the toggle.
if (s.hyprlandFollowMouse === 0 || s.hyprlandFollowMouse === 1)
add("input:follow_mouse", `follow_mouse = ${s.hyprlandFollowMouse},`);
if (s.mouseAccelSpeed !== 0)
add("input:sensitivity", `sensitivity = ${s.mouseAccelSpeed.toFixed(2)},`);
if (s.mouseAccelProfile !== "default")
+31 -1
View File
@@ -24,7 +24,7 @@ const scope = vm.createContext({
liveMonitor: name => live[name] ?? null,
hyprOptionNames: new Proxy({}, { get: (names, key) => key in names ? names[key] : true })
});
for (const fn of ["getOutputIdentifier", "luaQuoted", "forceFlagValue", "buildOutputsLua", "luaString", "luaTable", "hyprSupports", "blurLines", "decorationLines", "groupbarLines", "hyprScrollMethod", "touchpadNames", "inputLines", "touchpadDeviceLines"])
for (const fn of ["getOutputIdentifier", "luaQuoted", "forceFlagValue", "buildOutputsLua", "luaString", "luaTable", "hyprSupports", "blurLines", "decorationLines", "groupbarLines", "hyprScrollMethod", "touchpadNames", "inputLines", "touchpadDeviceLines", "refreshFollowMouse"])
vm.runInContext(extract(fn), scope);
const withOptions = (names, run) => {
const previous = scope.hyprOptionNames;
@@ -144,6 +144,36 @@ test("input: turning natural scroll off is written so the template's true cannot
assert.ok(table(inputLines(input({ touchpadNaturalScroll: false })), "touchpad").includes("natural_scroll = false,"));
});
test("input: focus follows mouse writes both on and off, leaves unmanaged modes alone, and skips unsupported options", () => {
const modeLine = mode => inputLines(input({ hyprlandFollowMouse: mode })).filter(line => line.startsWith("follow_mouse ="));
assert.deepEqual(modeLine(1), ["follow_mouse = 1,"]);
assert.deepEqual(modeLine(0), ["follow_mouse = 0,"]);
assert.deepEqual(modeLine(-1), []);
withOptions({}, () => {
assert.deepEqual(modeLine(1), []);
});
});
test("input: the live focus mode accepts all Hyprland modes and keeps the last value on failed reads", () => {
scope.CompositorService = { isHyprland: true };
scope.log = { warn() {} };
let callback;
scope.Proc = { runCommand(id, args, cb) { callback = cb; } };
for (const mode of [0, 1, 2, 3]) {
scope.refreshFollowMouse();
callback(JSON.stringify({ int: mode }), 0);
assert.equal(scope.followMouseMode, mode);
}
for (const [output, status] of [["", 1], ["invalid", 0], ['{"int":4}', 0], ['{"int":"1"}', 0]]) {
scope.refreshFollowMouse();
callback(output, status);
assert.equal(scope.followMouseMode, 3);
}
scope.CompositorService.isHyprland = false;
callback('{"int":1}', 0);
assert.equal(scope.followMouseMode, 3);
});
test("input: keyboard keys stay out until one is configured, then numlock is explicit and kb_file replaces the xkb names", () => {
const untouched = inputLines(input({ keyboardTrackLayout: "window" }));
assert.equal(untouched.some(line => /^(kb_|repeat_|numlock)/.test(line)), false);
@@ -49,3 +49,15 @@ test("a coerced key normalises a percentage and leaves a unit value alone", () =
assert.equal(loaded({ popupTransparency: 50 }).popupTransparency, 0.5);
assert.equal(loaded({ popupTransparency: 0.5 }).popupTransparency, 0.5);
});
test("a focus override persists both off and on, and resetting removes it", () => {
const root = defaultRoot();
for (const mode of [0, 1]) {
root.hyprlandFollowMouse = mode;
const json = store.toJson(root, new Set());
assert.equal(json.hyprlandFollowMouse, mode);
assert.equal(loaded(json).hyprlandFollowMouse, mode);
}
root.hyprlandFollowMouse = -1;
assert.equal("hyprlandFollowMouse" in store.toJson(root, new Set()), false);
});
@@ -14375,6 +14375,29 @@
"parentLabel": "Input",
"conditionKey": "pointerCapable"
},
{
"section": "hyprlandFollowMouse",
"label": "Focus follows mouse",
"tabIndex": 44,
"category": "Mouse & touchpad",
"keywords": [
"focus",
"follow",
"follows",
"hyprland",
"input",
"mouse",
"moves",
"over",
"pointer",
"touchpad",
"window",
"windows"
],
"parentLabel": "Input",
"description": "Focus windows when the pointer moves over them.",
"conditionKey": "isHyprland"
},
{
"section": "mouseLeftHanded",
"label": "Left-handed",
@@ -14431,14 +14454,19 @@
"category": "Mouse & touchpad",
"keywords": [
"acceleration",
"focus",
"input",
"mouse",
"moves",
"over",
"pointer",
"sensitivity",
"touchpad"
"touchpad",
"windows"
],
"parentLabel": "Input",
"icon": "mouse",
"description": "Focus windows when the pointer moves over them.",
"conditionKey": "inputCapable"
},
{