// Mouse & Touchpad. // // GNOME splits pointing devices out of Keyboard, and so does this: the settings // are unrelated, and burying pointer speed under a page called "Shortcuts" is // where it was before. These all reach Hyprland's input section, which until now // could only be changed by editing hypr/input.lua by hand. // // The touchpad card renders only when a touchpad is attached. On a desktop it // would be worse than useless -- every switch on it appears to work, because the // preference is stored and the compositor accepts an option for a device class // with no members, so the user would be toggling settings that can never affect // anything with nothing to say so. // // Gestures are a card again. They were folded into the touchpad card when the // only thing to say about them was how far a swipe travels and which way round // it goes -- a heading standing in for a section. Four assignable four-finger // directions is a subject, so the heading has content now, and the two feel // knobs come back up with it: they are about gestures, not about the touchpad. import QtQuick import Quickshell import qs.config import qs.services import qs.modules.clipboard SettingsPage { id: root title: "Mouse & Touchpad" lede: InputDevices.hasTouchpad ? "Pointer behavior for your mouse and touchpad." : "Pointer behavior. Touchpad settings appear when a touchpad is attached." // The name-filtered device lists InputDevices publishes. Defaulted here so // the card stays a short honest list even before the service has read the // compositor for the first time. readonly property var realKeyboards: InputDevices.realKeyboards ?? [] readonly property var realMice: InputDevices.realMice ?? [] // Keyboards, then mice, then the touchpad if there is one. Built as one // list so the card can draw dividers between rows without each Repeater // having to know what follows it. readonly property var devices: { const out = []; for (const keyboard of root.realKeyboards) out.push({ glyph: "\u{F030C}", // md-keyboard label: String(keyboard.pretty ?? keyboard.name ?? ""), detail: InputDevices.mainKeyboardLayout === "" ? "Keyboard" : "Keyboard · " + InputDevices.mainKeyboardLayout }); for (const mouse of root.realMice) out.push({ glyph: "\u{F037D}", // md-mouse label: String(mouse.pretty ?? mouse.name ?? ""), detail: "Mouse" }); if (InputDevices.hasTouchpad) out.push({ glyph: "\u{F0621}", // md-gesture-tap label: "Touchpad", detail: "Its own card above, because libinput keeps it separate" }); return out; } readonly property var scrollMethodSpec: PreferenceSchema.spec("scrollMethod") // Scroll button only means something while the method is the held-button // one, and only if the schema still offers that method at all -- the value // comes from what Hyprland's own description of input:scroll_method // accepts, so this asks the schema rather than assuming. readonly property bool heldButtonScrolling: { const options = root.scrollMethodSpec && root.scrollMethodSpec.options ? root.scrollMethodSpec.options : []; if (!options.some(option => option.value === "on_button_down")) return false; return DesktopPreferences.get("scrollMethod") === "on_button_down"; } SettingsCard { title: "Mouse" subtitle: "Applied to every pointing device that is not a touchpad." SliderRow { setting: "pointerSensitivity" } OptionPickerRow { id: accelRow readonly property var spec: PreferenceSchema.spec("accelProfile") label: accelRow.spec ? accelRow.spec.label : "Acceleration" detail: accelRow.spec ? accelRow.spec.detail : "" options: accelRow.spec && accelRow.spec.options ? accelRow.spec.options : [] current: DesktopPreferences.get("accelProfile") onPicked: value => SystemSettings.commitPreference("accelProfile", value) } SliderRow { setting: "scrollFactor" } ToggleRow { setting: "naturalScroll" } ToggleRow { setting: "leftHanded" } ToggleRow { setting: "middleClickPaste" detail: "Paste the primary selection in GTK and native Wayland applications; individual applications may choose not to support it" } OptionPickerRow { id: scrollMethodRow label: root.scrollMethodSpec ? root.scrollMethodSpec.label : "Scroll method" detail: root.scrollMethodSpec ? root.scrollMethodSpec.detail : "" options: root.scrollMethodSpec && root.scrollMethodSpec.options ? root.scrollMethodSpec.options : [] current: DesktopPreferences.get("scrollMethod") divider: scrollButtonRow.visible onPicked: value => SystemSettings.commitPreference("scrollMethod", value) } // Hidden on every other method: the number stays valid and stored, but // a control that governs nothing is exactly what this page exists to // stop shipping. SliderRow { id: scrollButtonRow setting: "scrollButton" visible: root.heldButtonScrolling divider: false } } // ── Gestures ──────────────────────────────────────────────────────────── // Three fingers are the desktop's and stay put: they are registered at // config time, they reproduce GNOME's muscle memory, and a preference that // needed a reload to turn them off would be worse than the nothing they // cost. Four fingers are the user's, from the same named-action vocabulary // custom shortcuts use -- an enum kind and a validated target, never a // command. Assigning one is read at config time too, so it reloads. readonly property var gestureKeys: [ { key: "gestureFourUp", label: "Swipe up" }, { key: "gestureFourDown", label: "Swipe down" }, { key: "gestureFourLeft", label: "Swipe left" }, { key: "gestureFourRight", label: "Swipe right" } ] // A named action is two fields; OptionPickerRow picks one value. They are // joined on the first colon, which `window` targets already use for // "workspace:4", so the split has to take the first one only. function gestureValue(key: string): string { const stored = DesktopPreferences.get(key); if (Keybinds.describeAction(stored) === "") return ""; return String(stored.kind) + ":" + String(stored.target); } function assignGesture(key: string, value: string): void { const at = String(value).indexOf(":"); if (at < 0) { DesktopPreferences.set(key, ({})); Keybinds.applyReload(); return; } const kind = String(value).slice(0, at); const target = String(value).slice(at + 1); const label = kind === "shell" ? Keybinds.shellActionLabel(target) : Keybinds.windowActionLabel(target); if (Keybinds.describeAction({ kind: kind, target: target }) === "" || label === "") return; DesktopPreferences.set(key, { kind: kind, target: target, label: label }); Keybinds.applyReload(); } // Nothing, then an application, then the shell's own actions, then the // compositor's window verbs -- the same lists the shortcut editor offers, // read from Keybinds so this page never becomes a second opinion on what // resolves. // // Applications cannot be listed inline: a normal machine has several // hundred desktop entries and a picker of this shape would be a page nobody // can read. So the option is a door rather than a list -- picking it opens // the same search the shortcut editor uses, below the row. A gesture that // already holds an application shows it too, so it can be read and cleared // without going back through the search. readonly property string appPickerValue: "pick-application" function gestureOptions(key: string): var { const out = [{ value: "", label: "Nothing", detail: "The swipe passes through to whatever is under it" }]; const stored = DesktopPreferences.get(key); if (stored && stored.kind === "app" && Keybinds.describeAction(stored) !== "") out.push({ value: "app:" + String(stored.target), label: String(stored.label), detail: "Application · launch-or-focus" }); out.push({ value: root.appPickerValue, label: "Launch an application…", detail: "Search everything installed, the way a custom shortcut does" }); for (const action of Keybinds.shellActions) out.push({ value: "shell:" + action.target, label: action.label, detail: "Shell action" }); for (const action of Keybinds.windowActions) out.push({ value: "window:" + action.target, label: action.label, detail: "Window & workspace" }); return out; } // Which gesture is currently choosing an application, "" while none is. property string pickingAppFor: "" // Capped at eight and only ever searched, never browsed -- the same rule // and the same reason as CustomShortcutEditor's list. readonly property var appMatches: { const needle = gestureAppSearch.text.trim().toLowerCase(); if (needle === "") return []; const out = []; for (const entry of DesktopEntries.applications.values) { if (entry.noDisplay) continue; if (String(entry.name).toLowerCase().indexOf(needle) >= 0) out.push(entry); if (out.length >= 8) break; } return out; } // The one path that writes an application onto a gesture. It builds the // same three-field record assignGesture() writes for the named actions -- // an entry id is never a command, and hypr/actions.lua resolves it the same // way it resolves one written by a custom shortcut. function assignGestureApp(key: string, entry: var): void { const target = String(entry?.id ?? ""); if (Keybinds.describeAction({ kind: "app", target: target }) === "") return; DesktopPreferences.set(key, { kind: "app", target: target, label: "Launch " + String(entry?.name ?? target) }); root.closeAppPicker(); Keybinds.applyReload(); } function closeAppPicker(): void { root.pickingAppFor = ""; gestureAppSearch.text = ""; } SettingsCard { visible: InputDevices.hasTouchpad title: "Gestures" subtitle: "Three fingers are Panama's — they drive workspaces and Mission Control everywhere and stay put. Four fingers are yours: give each direction a job from the same actions your shortcuts use, or leave it unassigned." SectionLabel { text: "Three fingers"; count: "· shipped" } TextRow { label: "Swipe left or right" detail: "Moves between workspaces, following your fingers" value: "Switch workspace" } TextRow { label: "Swipe up" value: "Open Mission Control" } TextRow { label: "Swipe down" detail: "Open and close rather than one toggle, so a swipe never undoes itself mid-gesture" value: "Close Mission Control" divider: false } SectionLabel { text: "Four fingers"; count: "· yours" } Repeater { model: root.gestureKeys OptionPickerRow { id: gestureRow required property var modelData required property int index label: String(gestureRow.modelData.label) detail: gestureRow.index === root.gestureKeys.length - 1 ? "Assigning one reloads the compositor — a beat of black, then it works" : "" options: root.gestureOptions(String(gestureRow.modelData.key)) current: root.gestureValue(String(gestureRow.modelData.key)) enabled: !Keybinds.reloading divider: gestureRow.index < root.gestureKeys.length - 1 onPicked: value => { if (String(value) === root.appPickerValue) { root.pickingAppFor = String(gestureRow.modelData.key); gestureAppSearch.text = ""; return; } root.closeAppPicker(); root.assignGesture(String(gestureRow.modelData.key), String(value)); } } } // The search, unfolded under the four rows rather than inside one of // them: the picker has already collapsed by the time this appears, and // a list of eight results inside a collapsed row has nowhere to go. Column { width: parent.width visible: root.pickingAppFor !== "" spacing: 0 Item { width: parent.width height: 40 SearchField { id: gestureAppSearch anchors.left: parent.left anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter placeholder: "Search installed applications" } } Repeater { model: root.appMatches SettingRow { id: candidate required property var modelData required property int index width: parent.width label: String(candidate.modelData.name ?? "") detail: String(candidate.modelData.id ?? "") controlWidth: 96 divider: candidate.index < root.appMatches.length - 1 activatable: true onActivated: root.assignGestureApp(root.pickingAppFor, candidate.modelData) } } TextRow { visible: root.appMatches.length === 0 label: gestureAppSearch.text.trim() === "" ? "Type to find an application" : "Nothing installed matches that" detail: "The gesture stores the application, never a command — the same three fields a custom shortcut stores" divider: false } } SectionLabel { text: "Feel" } // How far a swipe has to travel and which way round it goes: the two // knobs that are about gestures rather than about the touchpad, which // is why they moved up out of the Touchpad card. SliderRow { setting: "swipeDistance" } ToggleRow { setting: "swipeInvert"; divider: false } } SettingsCard { visible: InputDevices.hasTouchpad title: "Touchpad" subtitle: "Separate from the mouse on purpose: libinput keeps them apart, and a touchpad and a mouse usually want to scroll in opposite directions. The two swipe knobs moved up into Gestures, which is what they are about." ToggleRow { setting: "touchpadTapToClick" } ToggleRow { setting: "touchpadClickfinger" } ToggleRow { setting: "touchpadTapAndDrag" } ChoiceRow { setting: "touchpadDragLock" } ToggleRow { setting: "touchpadNaturalScroll" } SliderRow { setting: "touchpadScrollFactor" } ToggleRow { setting: "touchpadDisableWhileTyping" } ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false } } SettingsCard { title: "Pointer behavior" OptionPickerRow { id: followMouseRow readonly property var spec: PreferenceSchema.spec("followMouse") label: followMouseRow.spec ? followMouseRow.spec.label : "Pointer focus" detail: followMouseRow.spec ? followMouseRow.spec.detail : "" options: followMouseRow.spec && followMouseRow.spec.options ? followMouseRow.spec.options : [] current: DesktopPreferences.get("followMouse") onPicked: value => SystemSettings.commitPreference("followMouse", value) } OptionPickerRow { id: focusOnCloseRow readonly property var spec: PreferenceSchema.spec("focusOnClose") label: focusOnCloseRow.spec ? focusOnCloseRow.spec.label : "When a window closes" detail: focusOnCloseRow.spec ? focusOnCloseRow.spec.detail : "" options: focusOnCloseRow.spec && focusOnCloseRow.spec.options ? focusOnCloseRow.spec.options : [] current: DesktopPreferences.get("focusOnClose") onPicked: value => SystemSettings.commitPreference("focusOnClose", value) } ToggleRow { setting: "cursorHideWhileTyping" } SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never" } ToggleRow { setting: "cursorWarpOnWorkspaceChange" } SliderRow { setting: "cursorSize"; divider: false } } SettingsCard { title: "Try it" subtitle: "Every setting above applies live — draw and scroll to feel them. Nothing here is saved." InputTestArea {} } SettingsCard { title: "Connected devices" subtitle: "What the compositor sees, minus the phantoms: transceiver siblings, virtual keyboards and consumer-control endpoints that are not devices anybody types on." Repeater { model: root.devices TextRow { id: deviceRow required property var modelData required property int index width: parent ? parent.width : 620 icon: String(deviceRow.modelData.glyph) label: String(deviceRow.modelData.label) detail: String(deviceRow.modelData.detail) divider: deviceRow.index < root.devices.length - 1 } } TextRow { visible: root.devices.length === 0 label: "No input devices reported" detail: "The compositor answered with nothing this page could name" divider: false } } }