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;