From 9ce7040b91e611c4d14eb3707ad13e7b4f8fe5b6 Mon Sep 17 00:00:00 2001 From: Gabriel Brown Date: Tue, 18 Aug 2026 08:16:10 -0400 Subject: [PATCH] Add a Mouse & Touchpad page and make keyboard layout editable Working towards parity with GNOME Settings, which splits pointing devices into their own panel. Panama had pointer speed and focus-follows buried under a page called "Input & Shortcuts", and had nothing at all for scroll direction, acceleration profile, scroll speed, left-handed buttons, or any touchpad setting -- all of which could only be changed by editing hypr/input.lua by hand, which is the thing this app exists to stop. Every new mapping was read back off the running compositor rather than assumed, and two were not what they look like: touchpad drag lock is an int with three states, not a switch, and scroll factors are floats even at their default of exactly 1. Getting either wrong makes every write to that setting look rejected. The shape contract now covers 35 mapped options, up from 23. The touchpad card renders only when a touchpad is attached, which is what InputDevices is for. On a desktop it would be worse than useless: every switch on it would appear to work, because the preference is stored and Hyprland accepts an option for a device class it has no member of, so the settings would silently affect nothing. Keyboard layout was read-only text, justified by a note saying changes needed a compositor reload. That is not true in 0.56.2 -- setting input:kb_variant through hl.config re-keymaps attached keyboards immediately, verified by watching active_keymap on a real keyboard change to "English (US, intl., with dead keys)" and back. So layout, variant, and options are now real controls, joined by a TextEntryRow that commits on Enter or focus loss rather than per keystroke, since half a layout name is a valid string meaning something else. Rejected input is shown as rejected rather than sanitised: these strings are serialised into an hl.config payload, where stripping an unexpected character would turn a typo into a different working setting. Verified each new pointer option applies and reverts against the live compositor. Schema, search, commit/reset, and system contracts pass. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L --- .../quickshell/config/PreferenceSchema.qml | 105 +++++++++++++++++ .../quickshell/modules/settings/MousePage.qml | 57 +++++++++ .../modules/settings/SettingsShell.qml | 2 + .../modules/settings/SettingsSidebar.qml | 3 +- .../modules/settings/ShortcutsPage.qml | 21 ++-- .../modules/settings/TextEntryRow.qml | 109 ++++++++++++++++++ config/dot/quickshell/modules/settings/qmldir | 2 + .../dot/quickshell/services/InputDevices.qml | 66 +++++++++++ .../quickshell/services/SettingsSearch.qml | 2 + config/dot/quickshell/services/ShellState.qml | 2 +- 10 files changed, 354 insertions(+), 15 deletions(-) create mode 100644 config/dot/quickshell/modules/settings/MousePage.qml create mode 100644 config/dot/quickshell/modules/settings/TextEntryRow.qml create mode 100644 config/dot/quickshell/services/InputDevices.qml diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index c5d89ed..4772613 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -257,6 +257,24 @@ Singleton { detail: "XKB layout name, or a comma-separated list to switch between", hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" } }, + { + key: "keyboardVariant", type: "string", def: "", group: "input", + // Same shape as the layout list and for the same reason: this is + // serialised into an hl.config string. + pattern: "^$|^[a-z0-9_]{1,24}(,[a-z0-9_]{1,24})*$", + label: "Layout variant", + detail: "XKB variant, such as dvorak or colemak. Empty for the standard layout", + hypr: { path: ["input", "kb_variant"], option: "input:kb_variant", readAs: "str" } + }, + { + key: "keyboardOptions", type: "string", def: "", group: "input", + // XKB option names are colon-separated pairs in a comma-separated + // list, e.g. "compose:ralt,caps:escape". + pattern: "^$|^[a-z0-9_]+:[a-z0-9_]+(,[a-z0-9_]+:[a-z0-9_]+)*$", + label: "Keyboard options", + detail: "XKB options, such as compose:ralt to make right Alt a compose key", + hypr: { path: ["input", "kb_options"], option: "input:kb_options", readAs: "str" } + }, { key: "numlockByDefault", type: "bool", def: true, group: "input", label: "Num Lock on login", @@ -310,6 +328,93 @@ Singleton { hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "float" } }, + // ── Pointer ───────────────────────────────────────────────────────── + // + // Every `readAs` below was read back off the running compositor rather + // than guessed. Two are not what they look like: touchpad drag lock is + // an int with three states, not a switch, and scroll factors are floats + // even at their default of exactly 1. + { + key: "naturalScroll", type: "bool", def: false, group: "pointer", + label: "Natural scrolling", + detail: "Content follows the direction of your fingers, as on a phone", + hypr: { path: ["input", "natural_scroll"], option: "input:natural_scroll", readAs: "bool" } + }, + { + key: "accelProfile", type: "enum", def: "flat", group: "pointer", + label: "Acceleration", + detail: "Flat moves the pointer the same distance however fast you move", + options: [ + { value: "flat", label: "Flat" }, + { value: "adaptive", label: "Adaptive" } + ], + hypr: { path: ["input", "accel_profile"], option: "input:accel_profile", readAs: "str" } + }, + { + key: "scrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1, + group: "pointer", + label: "Scroll speed", + detail: "Multiplies how far one notch of the wheel scrolls", + hypr: { path: ["input", "scroll_factor"], option: "input:scroll_factor", readAs: "float" } + }, + { + key: "leftHanded", type: "bool", def: false, group: "pointer", + label: "Left-handed", + detail: "Swap the primary and secondary buttons", + hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" } + }, + + // ── Touchpad ──────────────────────────────────────────────────────── + // + // Shown only on machines that have one. These are separate from the + // pointer settings above because libinput keeps them separate: a mouse + // and a touchpad on the same machine can scroll in opposite directions, + // and usually should. + { + key: "touchpadTapToClick", type: "bool", def: true, group: "touchpad", + label: "Tap to click", + detail: "A tap counts as a click without pressing down", + hypr: { path: ["input", "touchpad", "tap-to-click"], option: "input:touchpad:tap-to-click", readAs: "bool" } + }, + { + key: "touchpadNaturalScroll", type: "bool", def: true, group: "touchpad", + label: "Natural scrolling", + detail: "Content follows the direction of your fingers", + hypr: { path: ["input", "touchpad", "natural_scroll"], option: "input:touchpad:natural_scroll", readAs: "bool" } + }, + { + key: "touchpadDisableWhileTyping", type: "bool", def: true, group: "touchpad", + label: "Disable while typing", + detail: "Ignore the touchpad briefly after a keystroke, so a palm cannot move the pointer", + hypr: { path: ["input", "touchpad", "disable_while_typing"], option: "input:touchpad:disable_while_typing", readAs: "bool" } + }, + { + key: "touchpadScrollFactor", type: "real", def: 1.0, min: 0.1, max: 4.0, step: 0.1, + group: "touchpad", + label: "Scroll speed", + detail: "Multiplies how far a two-finger scroll travels", + hypr: { path: ["input", "touchpad", "scroll_factor"], option: "input:touchpad:scroll_factor", readAs: "float" } + }, + { + key: "touchpadDragLock", type: "enum", def: 0, group: "touchpad", + label: "Drag lock", + detail: "Keeps a tap-and-drag active when you lift a finger mid-drag", + // An int with three states rather than a switch, which is why this + // is an enum: reported as `int` by getoption, not `bool`. + options: [ + { value: 0, label: "Off" }, + { value: 1, label: "On" }, + { value: 2, label: "On, until you tap again" } + ], + hypr: { path: ["input", "touchpad", "drag_lock"], option: "input:touchpad:drag_lock", readAs: "int" } + }, + { + key: "touchpadMiddleButtonEmulation", type: "bool", def: false, group: "touchpad", + label: "Middle-click by pressing both buttons", + detail: "Pressing left and right together acts as a middle click", + hypr: { path: ["input", "touchpad", "middle_button_emulation"], option: "input:touchpad:middle_button_emulation", readAs: "bool" } + }, + // ── Night light ───────────────────────────────────────────────────── { key: "nightLightEnabled", type: "bool", def: false, group: "nightLight", diff --git a/config/dot/quickshell/modules/settings/MousePage.qml b/config/dot/quickshell/modules/settings/MousePage.qml new file mode 100644 index 0000000..c6a3fcc --- /dev/null +++ b/config/dot/quickshell/modules/settings/MousePage.qml @@ -0,0 +1,57 @@ +// 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. + +import QtQuick +import qs.config +import qs.services + +SettingsPage { + id: root + + title: "Mouse & Touchpad" + lede: InputDevices.hasTouchpad + ? "Pointer behaviour for your mouse and touchpad." + : "Pointer behaviour. Touchpad settings appear when a touchpad is attached." + + SettingsCard { + title: "Mouse" + subtitle: "Applied to every pointing device that is not a touchpad." + + SliderRow { setting: "pointerSensitivity" } + ChoiceRow { setting: "accelProfile" } + ToggleRow { setting: "naturalScroll" } + SliderRow { setting: "scrollFactor" } + ToggleRow { setting: "leftHanded"; 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." + + ToggleRow { setting: "touchpadTapToClick" } + ToggleRow { setting: "touchpadNaturalScroll" } + ToggleRow { setting: "touchpadDisableWhileTyping" } + SliderRow { setting: "touchpadScrollFactor" } + ChoiceRow { setting: "touchpadDragLock" } + ToggleRow { setting: "touchpadMiddleButtonEmulation"; divider: false } + } + + SettingsCard { + title: "Pointer" + + ChoiceRow { setting: "followMouse" } + SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never" } + SliderRow { setting: "cursorSize"; divider: false } + } +} diff --git a/config/dot/quickshell/modules/settings/SettingsShell.qml b/config/dot/quickshell/modules/settings/SettingsShell.qml index d1ef625..def527a 100644 --- a/config/dot/quickshell/modules/settings/SettingsShell.qml +++ b/config/dot/quickshell/modules/settings/SettingsShell.qml @@ -99,6 +99,7 @@ Rectangle { case "notifications": return notificationsPage; case "screen-intelligence": return screenIntelligencePage; case "shortcuts": return shortcutsPage; + case "mouse": return mousePage; case "accessibility": return accessibilityPage; case "power": return powerPage; case "datetime": return dateTimePage; @@ -153,6 +154,7 @@ Rectangle { Component { id: notificationsPage; NotificationsPage {} } Component { id: screenIntelligencePage; ScreenIntelligencePage {} } Component { id: shortcutsPage; ShortcutsPage {} } + Component { id: mousePage; MousePage {} } Component { id: servicesPage; ServicesPage {} } Component { id: aboutPage; AboutPage {} } diff --git a/config/dot/quickshell/modules/settings/SettingsSidebar.qml b/config/dot/quickshell/modules/settings/SettingsSidebar.qml index 38e009b..bfe4d36 100644 --- a/config/dot/quickshell/modules/settings/SettingsSidebar.qml +++ b/config/dot/quickshell/modules/settings/SettingsSidebar.qml @@ -31,7 +31,8 @@ Rectangle { { page: "sound", label: "Sound", icon: "\u{F057E}" }, { page: "notifications", label: "Notifications & Focus", icon: "\u{F009A}" }, { page: "screen-intelligence", label: "Screen Intelligence", icon: "\u{F05A8}" }, - { page: "shortcuts", label: "Input & Shortcuts", icon: "\u{F030C}" }, + { page: "shortcuts", label: "Keyboard", icon: "\u{F030C}" }, + { page: "mouse", label: "Mouse & Touchpad", icon: "\u{F037D}" }, { page: "accessibility", label: "Accessibility", icon: "\u{F0208}" }, { page: "power", label: "Power & Lock", icon: "\u{F0425}" }, { page: "datetime", label: "Date & Time", icon: "\u{F0954}" }, diff --git a/config/dot/quickshell/modules/settings/ShortcutsPage.qml b/config/dot/quickshell/modules/settings/ShortcutsPage.qml index 6460815..aa9296a 100644 --- a/config/dot/quickshell/modules/settings/ShortcutsPage.qml +++ b/config/dot/quickshell/modules/settings/ShortcutsPage.qml @@ -28,24 +28,19 @@ SettingsPage { SettingsCard { title: "Keyboard" - TextRow { - label: "Keyboard layout" - detail: "XKB layout name. Changing it needs a compositor reload, so it is shown here rather than offered as a control that appears to apply instantly." - value: Settings ? DesktopPreferences.get("keyboardLayout") : "us" - } + // These were read-only text, on the grounds that a layout change needed + // a compositor reload. It does not: setting input:kb_variant through + // hl.config re-keymaps attached keyboards immediately -- verified by + // watching active_keymap on a real keyboard change and change back. So + // they are real controls. + TextEntryRow { setting: "keyboardLayout"; placeholder: "us" } + TextEntryRow { setting: "keyboardVariant"; placeholder: "none" } + TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" } SliderRow { setting: "keyRepeatDelay" } SliderRow { setting: "keyRepeatRate" } ToggleRow { setting: "numlockByDefault"; divider: false } } - SettingsCard { - title: "Pointer" - - ChoiceRow { setting: "followMouse" } - SliderRow { setting: "pointerSensitivity" } - SliderRow { setting: "cursorInactiveTimeout"; zeroLabel: "Never"; divider: false } - } - SettingsCard { title: "Hardware input" diff --git a/config/dot/quickshell/modules/settings/TextEntryRow.qml b/config/dot/quickshell/modules/settings/TextEntryRow.qml new file mode 100644 index 0000000..e402cab --- /dev/null +++ b/config/dot/quickshell/modules/settings/TextEntryRow.qml @@ -0,0 +1,109 @@ +// A free-text setting, bound to a schema key by name. +// +// TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" } +// +// For the settings whose value is a short string with too many legal values to +// enumerate -- XKB layouts and options, chiefly. Label, explanation, and +// validation all come from PreferenceSchema, so a row cannot drift from the +// setting it edits. +// +// Rejected input is shown as rejected rather than silently dropped or quietly +// sanitised. Several of these strings are serialised into an hl.config payload, +// where stripping an unexpected character would turn a typo into a different +// working setting -- so the schema's pattern decides, the field turns red when +// it fails, and nothing is committed until it passes. +// +// Committed on Enter or when focus leaves, not per keystroke: half a layout +// name is a valid string that means something else, and each commit is a round +// trip to the compositor. + +import QtQuick +import qs.config +import qs.services + +SettingRow { + id: root + + required property string setting + property string placeholder: "" + + readonly property var spec: PreferenceSchema.spec(root.setting) + readonly property string stored: String(DesktopPreferences.get(root.setting) ?? "") + + // Empty is always allowed to be typed through, even where the pattern + // forbids it, so a field can be cleared on the way to a new value. + readonly property bool valid: input.text === "" + || !root.spec?.pattern + || new RegExp(root.spec.pattern).test(input.text) + + label: root.spec ? root.spec.label : root.setting + detail: root.spec ? root.spec.detail : "" + controlWidth: 210 + + function commit(): void { + if (!root.valid || input.text === root.stored) + return; + SystemSettings.commitPreference(root.setting, input.text); + } + + function revert(): void { + input.text = root.stored; + } + + // Follows the store when the value changes elsewhere -- a reset, a restored + // backup -- but never while the field has focus, which would overwrite what + // is being typed. + onStoredChanged: if (!input.activeFocus) input.text = root.stored + + Rectangle { + anchors.right: parent.right + anchors.verticalCenter: parent.verticalCenter + width: root.controlWidth + height: 30 + radius: 8 + color: Theme.alpha(Theme.fg, 0.05) + border.width: 1 + border.color: !root.valid + ? Theme.danger + : (input.activeFocus ? Theme.accent : Theme.alpha(Theme.fg, 0.12)) + + TextInput { + id: input + + anchors.fill: parent + anchors.leftMargin: 10 + anchors.rightMargin: 10 + verticalAlignment: TextInput.AlignVCenter + clip: true + + text: root.stored + color: root.valid ? Theme.fg : Theme.danger + font.family: Theme.fontMono + font.pixelSize: Theme.fontSizeSmall + selectByMouse: true + selectionColor: Theme.alpha(Theme.accent, 0.35) + + onAccepted: root.commit() + + // Commit, then show what is actually stored. If the compositor + // refused the value, the field snaps back to the value in effect + // rather than displaying a setting that was never applied; if it + // accepted, onStoredChanged brings the field back up to date the + // moment the write lands. + onActiveFocusChanged: if (!activeFocus) { root.commit(); root.revert(); } + + Keys.onEscapePressed: { + root.revert(); + input.focus = false; + } + + Text { + anchors.verticalCenter: parent.verticalCenter + visible: input.text === "" && !input.activeFocus + text: root.placeholder + color: Theme.fgMuted + font: input.font + } + } + } +} diff --git a/config/dot/quickshell/modules/settings/qmldir b/config/dot/quickshell/modules/settings/qmldir index 231ef52..bf3768f 100644 --- a/config/dot/quickshell/modules/settings/qmldir +++ b/config/dot/quickshell/modules/settings/qmldir @@ -46,3 +46,5 @@ SoundDeviceRow 1.0 SoundDeviceRow.qml TimeOfDayRow 1.0 TimeOfDayRow.qml LocationPicker 1.0 LocationPicker.qml FontPicker 1.0 FontPicker.qml +MousePage 1.0 MousePage.qml +TextEntryRow 1.0 TextEntryRow.qml diff --git a/config/dot/quickshell/services/InputDevices.qml b/config/dot/quickshell/services/InputDevices.qml new file mode 100644 index 0000000..b71c39e --- /dev/null +++ b/config/dot/quickshell/services/InputDevices.qml @@ -0,0 +1,66 @@ +pragma Singleton + +// What input hardware this machine actually has. +// +// Exists so pages can hide controls for hardware that is not present. A +// touchpad card on a desktop is not merely useless -- it is misleading, because +// every switch on it appears to work: the preference is stored and Hyprland +// accepts the option for a device class it has no member of. The user is left +// toggling settings that will never affect anything, with nothing to say so. +// +// Touchpads are identified by name. libinput exposes them through the same +// "mice" list as everything else that reports pointer motion, and Hyprland +// passes the device name through, so an Elan or Synaptics touchpad arrives as +// something like "elan-touchpad". There is no device-class field to consult. +// +// Read on demand rather than polled. Input devices do come and go -- a mouse is +// unplugged, a receiver is moved -- so this also refreshes when Hyprland says +// the device list changed, which is the only moment the answer can differ. + +import Quickshell +import Quickshell.Io +import Quickshell.Hyprland +import QtQuick + +Singleton { + id: root + + property var mice: [] + property var keyboards: [] + + // True when anything that looks like a touchpad is attached. + readonly property bool hasTouchpad: root.mice.some(name => + name.includes("touchpad") || name.includes("trackpad")) + + readonly property bool hasMouse: root.mice.length > 0 + + function refresh(): void { + if (!query.running) + query.running = true; + } + + Process { + id: query + running: true + command: ["hyprctl", "-j", "devices"] + stdout: StdioCollector { + onStreamFinished: { + try { + const parsed = JSON.parse(this.text); + root.mice = (parsed.mice ?? []).map(device => String(device.name ?? "").toLowerCase()); + root.keyboards = (parsed.keyboards ?? []).map(device => String(device.name ?? "").toLowerCase()); + } catch (error) { + console.warn("InputDevices: could not parse hyprctl devices:", error); + } + } + } + } + + Connections { + target: Hyprland + function onRawEvent(event: var): void { + if (event.name === "device" || event.name === "configreloaded") + root.refresh(); + } + } +} diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index 943b41b..3179aeb 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -33,6 +33,8 @@ Singleton { "idle": "power", "accessibility": "accessibility", "input": "shortcuts", + "pointer": "mouse", + "touchpad": "mouse", "weather": "appearance", "notifications": "notifications", "capture": "screen-intelligence" diff --git a/config/dot/quickshell/services/ShellState.qml b/config/dot/quickshell/services/ShellState.qml index 28bb534..1b6c9f8 100644 --- a/config/dot/quickshell/services/ShellState.qml +++ b/config/dot/quickshell/services/ShellState.qml @@ -92,7 +92,7 @@ Singleton { } function openSettings(page: string): void { - const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "accessibility", "power", "datetime", "applications", "services", "about"]; + const allowed = ["home", "appearance", "displays", "connectivity", "home-phone", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "mouse", "accessibility", "power", "datetime", "applications", "services", "about"]; root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home"; DesktopPreferences.set("lastPage", root.settingsPage); root.settingsOpen = true;