451 lines
18 KiB
QML
451 lines
18 KiB
QML
// 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
|
|
}
|
|
}
|
|
}
|