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:
Gabriel Brown
2026-08-18 08:16:10 -04:00
parent 7541a45e77
commit 9ce7040b91
10 changed files with 354 additions and 15 deletions
@@ -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
}
}
}
}