From ce389d2e87eabf52ce425f51a65b219848df10a3 Mon Sep 17 00:00:00 2001 From: purian23 Date: Mon, 12 Oct 2026 02:27:21 -0400 Subject: [PATCH] feat(input): focus follows mouse on niri and mango --- quickshell/Common/SettingsData.qml | 2 +- quickshell/Common/settings/SettingsSpec.js | 2 +- .../Modules/Settings/MouseTouchpadTab.qml | 19 ++++++++++--------- quickshell/Services/CompositorService.qml | 2 ++ quickshell/Services/HyprlandService.qml | 12 ++---------- quickshell/Services/MangoService.qml | 1 + quickshell/Services/NiriService.qml | 4 ++++ quickshell/tests/config-include.test.mjs | 6 +++++- quickshell/tests/hyprland-lua.test.mjs | 2 +- .../tests/settings-default-load.test.mjs | 10 +++++----- .../translations/settings_search_index.json | 19 +++++-------------- 11 files changed, 37 insertions(+), 42 deletions(-) diff --git a/quickshell/Common/SettingsData.qml b/quickshell/Common/SettingsData.qml index e61e90d6a..f72cfad3e 100644 --- a/quickshell/Common/SettingsData.qml +++ b/quickshell/Common/SettingsData.qml @@ -245,7 +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 int focusFollowsMouse: Spec.SPEC.focusFollowsMouse.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 3b7b0944e..bebf71bf7 100644 --- a/quickshell/Common/settings/SettingsSpec.js +++ b/quickshell/Common/settings/SettingsSpec.js @@ -315,7 +315,7 @@ var LOCAL_SPEC = { def: -1, onChange: "updateCompositorLayout" }, - hyprlandFollowMouse: { + focusFollowsMouse: { def: -1, onChange: "updateCompositorInput" }, diff --git a/quickshell/Modules/Settings/MouseTouchpadTab.qml b/quickshell/Modules/Settings/MouseTouchpadTab.qml index 7266d7aa8..7a7eafdd5 100644 --- a/quickshell/Modules/Settings/MouseTouchpadTab.qml +++ b/quickshell/Modules/Settings/MouseTouchpadTab.qml @@ -33,15 +33,16 @@ Item { 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) + // Only Hyprland can report the live mode; untouched niri and Mango show their defaults + readonly property int inheritedMode: CompositorService.isHyprland ? HyprlandService.followMouseMode : CompositorService.isMango ? 1 : 0 + visible: CompositorService.supportsInputConfig && (!CompositorService.isHyprland || HyprlandService.hyprSupports("input:follow_mouse")) + enabled: inputInclude.included && !inputInclude.readOnly && (!CompositorService.isHyprland || (HyprlandService.luaConfigActive && HyprlandService.followMouseMode >= 0)) + tags: ["mouse", "focus", "follow", "pointer", "window", "sloppy"] + settingKey: "focusFollowsMouse" + resetKeys: ["focusFollowsMouse"] + text: I18n.tr("Focus follows mouse", "Toggle that focuses a window when the pointer moves over it") + checked: (SettingsData.focusFollowsMouse < 0 ? inheritedMode : SettingsData.focusFollowsMouse) === 1 + onToggled: checked => SettingsData.set("focusFollowsMouse", checked ? 1 : 0) } SettingsSliderRow { diff --git a/quickshell/Services/CompositorService.qml b/quickshell/Services/CompositorService.qml index 18baafcd6..a88d63ac2 100644 --- a/quickshell/Services/CompositorService.qml +++ b/quickshell/Services/CompositorService.qml @@ -483,6 +483,8 @@ Singleton { function onRawEvent(event) { if (event.name === "monitoraddedv2" || event.name === "monitorremoved" || event.name === "configreloaded") { + if (event.name === "configreloaded") + HyprlandService.refreshFollowMouse(); root.refreshHyprlandMonitorLayout(); return; } diff --git a/quickshell/Services/HyprlandService.qml b/quickshell/Services/HyprlandService.qml index 6f1cafce5..b75aafad8 100644 --- a/quickshell/Services/HyprlandService.qml +++ b/quickshell/Services/HyprlandService.qml @@ -239,14 +239,6 @@ Singleton { }); } - Connections { - target: CompositorService.isHyprland ? Hyprland : null - function onRawEvent(event) { - if (event.name === "configreloaded") - root.refreshFollowMouse(); - } - } - function loadHyprOptionNames() { if (!CompositorService.isHyprland || hyprOptionsReady || hyprOptionsLoading) return; @@ -848,8 +840,8 @@ hl.layer_rule({ 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.focusFollowsMouse === 0 || s.focusFollowsMouse === 1) + add("input:follow_mouse", `follow_mouse = ${s.focusFollowsMouse},`); if (s.mouseAccelSpeed !== 0) add("input:sensitivity", `sensitivity = ${s.mouseAccelSpeed.toFixed(2)},`); if (s.mouseAccelProfile !== "default") diff --git a/quickshell/Services/MangoService.qml b/quickshell/Services/MangoService.qml index 62a03de86..9122634c0 100644 --- a/quickshell/Services/MangoService.qml +++ b/quickshell/Services/MangoService.qml @@ -780,6 +780,7 @@ cursor_size=${size}`; add("mouse_middle_button_emulation", flag(s.mouseMiddleEmulation)); add("axis_scroll_factor", s.mouseScrollFactor.toFixed(2)); add("mouse_scroll_method", scrollMethod(s.mouseScrollMethod)); + add("sloppy_focus", s.focusFollowsMouse); add("tap_to_click", flag(s.touchpadTapToClick)); add("tap_and_drag", flag(s.touchpadTapAndDrag)); add("drag_lock", flag(s.touchpadDragLock)); diff --git a/quickshell/Services/NiriService.qml b/quickshell/Services/NiriService.qml index d4a835d90..12a451cb5 100644 --- a/quickshell/Services/NiriService.qml +++ b/quickshell/Services/NiriService.qml @@ -1748,6 +1748,10 @@ window-rule { `; let inputContent = dmsWarning + "input {\n"; + // Niri flags take no false, so off can only leave it out + if (typeof SettingsData !== "undefined" && SettingsData.focusFollowsMouse === 1) { + inputContent += " focus-follows-mouse max-scroll-amount=\"0%\"\n"; + } // Mouse block inputContent += " mouse {\n"; diff --git a/quickshell/tests/config-include.test.mjs b/quickshell/tests/config-include.test.mjs index f6cc76a0c..2d1af4e14 100644 --- a/quickshell/tests/config-include.test.mjs +++ b/quickshell/tests/config-include.test.mjs @@ -61,7 +61,7 @@ test("mango input fragment maps choices onto libinput numbers and spells numlock assert.ok(found, "mangoInputLines not found in MangoService.qml"); const scope = vm.createContext({}); vm.runInContext(found[0], scope); - const defaults = Object.fromEntries(Object.keys(SPEC).filter(key => /^(mouse|touchpad|keyboard)[A-Z]/.test(key)).map(key => [key, SPEC[key].def])); + const defaults = Object.fromEntries(Object.keys(SPEC).filter(key => /^(mouse|touchpad|keyboard)[A-Z]|^focusFollowsMouse$/.test(key)).map(key => [key, SPEC[key].def])); const lines = (overrides, snake = false) => [...scope.mangoInputLines(Object.assign({}, defaults, overrides), snake)]; const value = (list, key) => list.find(line => line.startsWith(key + "="))?.slice(key.length + 1); @@ -81,4 +81,8 @@ test("mango input fragment maps choices onto libinput numbers and spells numlock assert.equal(value(chosen, "repeat_delay"), "300"); assert.equal(value(chosen, "numlockon"), "0"); assert.equal(value(lines({ keyboardNumlock: true }, true), "numlock_on"), "1"); + + assert.equal(value(stock, "sloppy_focus"), undefined, "untouched focus mode stays Mango's"); + for (const mode of [0, 1]) + assert.equal(value(lines({ focusFollowsMouse: mode }), "sloppy_focus"), String(mode)); }); diff --git a/quickshell/tests/hyprland-lua.test.mjs b/quickshell/tests/hyprland-lua.test.mjs index 03337adf8..810adb07c 100644 --- a/quickshell/tests/hyprland-lua.test.mjs +++ b/quickshell/tests/hyprland-lua.test.mjs @@ -145,7 +145,7 @@ test("input: turning natural scroll off is written so the template's true cannot }); 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 =")); + const modeLine = mode => inputLines(input({ focusFollowsMouse: 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), []); diff --git a/quickshell/tests/settings-default-load.test.mjs b/quickshell/tests/settings-default-load.test.mjs index e8a41809a..3e28002ae 100644 --- a/quickshell/tests/settings-default-load.test.mjs +++ b/quickshell/tests/settings-default-load.test.mjs @@ -53,11 +53,11 @@ test("a coerced key normalises a percentage and leaves a unit value alone", () = 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; + root.focusFollowsMouse = mode; const json = store.toJson(root, new Set()); - assert.equal(json.hyprlandFollowMouse, mode); - assert.equal(loaded(json).hyprlandFollowMouse, mode); + assert.equal(json.focusFollowsMouse, mode); + assert.equal(loaded(json).focusFollowsMouse, mode); } - root.hyprlandFollowMouse = -1; - assert.equal("hyprlandFollowMouse" in store.toJson(root, new Set()), false); + root.focusFollowsMouse = -1; + assert.equal("focusFollowsMouse" in store.toJson(root, new Set()), false); }); diff --git a/quickshell/translations/settings_search_index.json b/quickshell/translations/settings_search_index.json index 596d782c8..39f093cd4 100644 --- a/quickshell/translations/settings_search_index.json +++ b/quickshell/translations/settings_search_index.json @@ -14376,7 +14376,7 @@ "conditionKey": "pointerCapable" }, { - "section": "hyprlandFollowMouse", + "section": "focusFollowsMouse", "label": "Focus follows mouse", "tabIndex": 44, "category": "Mouse & touchpad", @@ -14384,19 +14384,15 @@ "focus", "follow", "follows", - "hyprland", "input", "mouse", - "moves", - "over", "pointer", + "sloppy", "touchpad", - "window", - "windows" + "window" ], "parentLabel": "Input", - "description": "Focus windows when the pointer moves over them.", - "conditionKey": "isHyprland" + "conditionKey": "inputCapable" }, { "section": "mouseLeftHanded", @@ -14454,19 +14450,14 @@ "category": "Mouse & touchpad", "keywords": [ "acceleration", - "focus", "input", "mouse", - "moves", - "over", "pointer", "sensitivity", - "touchpad", - "windows" + "touchpad" ], "parentLabel": "Input", "icon": "mouse", - "description": "Focus windows when the pointer moves over them.", "conditionKey": "inputCapable" }, {