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
This commit is contained in:
@@ -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 }
|
||||
}
|
||||
}
|
||||
@@ -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 {} }
|
||||
|
||||
|
||||
@@ -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}" },
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user