From 80ca0f0ee5312937da7ce55764d1d37bcf9deba7 Mon Sep 17 00:00:00 2001 From: Perdixky <105138864+Perdixky@users.noreply.github.com> Date: Mon, 12 Oct 2026 14:15:46 +0800 Subject: [PATCH] hyprland: add focus follows mouse toggle (#3755) --- quickshell/Common/SettingsData.qml | 1 + quickshell/Common/settings/SettingsSpec.js | 4 +++ .../Modules/Settings/MouseTouchpadTab.qml | 12 +++++++ quickshell/Services/HyprlandService.qml | 31 ++++++++++++++++++ quickshell/tests/hyprland-lua.test.mjs | 32 ++++++++++++++++++- .../tests/settings-default-load.test.mjs | 12 +++++++ .../translations/settings_search_index.json | 30 ++++++++++++++++- 7 files changed, 120 insertions(+), 2 deletions(-) diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index bd573c071..e61e90d6a 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -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 diff --git a/quickshell/Common/settings/SettingsSpec.js b/quickshell/Common/settings/SettingsSpec.js index 3b9f79d9a..3b7b0944e 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -315,6 +315,10 @@ var LOCAL_SPEC = { def: -1, onChange: "updateCompositorLayout" }, + hyprlandFollowMouse: { + def: -1, + onChange: "updateCompositorInput" + }, mouseAccelProfile: { def: "default", onChange: "updateCompositorInput" diff --git a/quickshell/Modules/Settings/MouseTouchpadTab.qml b/quickshell/Modules/Settings/MouseTouchpadTab.qml index 268c1d84f..7266d7aa8 100644 --- a/quickshell/Modules/Settings/MouseTouchpadTab.qml +++ b/quickshell/Modules/Settings/MouseTouchpadTab.qml @@ -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" diff --git a/quickshell/Services/HyprlandService.qml b/quickshell/Services/HyprlandService.qml index 1af0bf222..6f1cafce5 100644 --- a/quickshell/Services/HyprlandService.qml +++ b/quickshell/Services/HyprlandService.qml @@ -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") diff --git a/quickshell/tests/hyprland-lua.test.mjs b/quickshell/tests/hyprland-lua.test.mjs index 750f29d3b..03337adf8 100644 --- a/quickshell/tests/hyprland-lua.test.mjs +++ b/quickshell/tests/hyprland-lua.test.mjs @@ -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); diff --git a/quickshell/tests/settings-default-load.test.mjs b/quickshell/tests/settings-default-load.test.mjs index df19b8a6f..e8a41809a 100644 --- a/quickshell/tests/settings-default-load.test.mjs +++ b/quickshell/tests/settings-default-load.test.mjs @@ -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); +}); diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index ad4626156..596d782c8 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -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" }, {