The audit's first tier, in one change: every case found where the interface
asserted something the system did not do.
Twenty-one compositor-owned preferences -- the whole Mouse & Touchpad page,
plus window layout, snapping, dim-inactive and the magnifier -- had the live
half (hyprctl eval) and not the config-time half, so they quietly reverted on
every hyprctl reload. All 70 hypr-backed keys now have a prefs.get() in the
Lua, and hypr-prefs-contract pins both the presence and that the Lua fallback
equals the schema default, which is how the touchpad page misreported natural
scrolling on first boot.
The idle generator fell back from unwritten battery keys to the AC values
while the Power page displayed the schema defaults: a fresh laptop showed
"suspend at 20 minutes" and generated no suspend listener, then discharged to
zero in a bag. Unwritten keys now use the defaults the page shows
(idle-defaults-contract pins generator to schema; idle-config-contract
re-pinned to the new rule with the tradeoff recorded), and change-settings
enables managed idle on any machine with a battery -- without starting
hypridle in whatever session the installer runs under.
The per-app lock-screen notification switches wrote fields nothing read:
hyprlock cannot render notifications. Removed, with the rule model shrunk to
{enabled}, stale stored fields dropped at normalization, and the contract now
forbidding the page from growing lock-screen switches it cannot honor.
The battery warning thresholds were searchable, documented as "Found on
Power & Lock", and rendered nowhere -- and crossing the low threshold changed
only a glyph's color. Both sliders now exist where search was already sending
people, and low battery publishes a real notification at important priority.
Three handoffs opened GNOME panels that are inert in a Hyprland session. The
keyboard handoff is gone (that panel writes gsettings nothing here reads, and
the working controls sat on the same page); Connectivity gains a Wi-Fi row
that opens GNOME's actual Wi-Fi panel -- hidden SSIDs and 802.1X finally have
a road -- beside the network row that legitimately drives NetworkManager; the
universal-access handoff is gone, its few working toggles being controls this
app already owns. And the accessibility page now gives the true reason sticky
keys are missing: each Wayland compositor implements its own and Hyprland
does not yet -- not "an X11 feature with no Wayland equivalent," which sent
people to the wrong conclusion about the platform.
Claude-Session: https://claude.ai/code/session_01Epx9ZC1gwm81K3jm9x9CKh
262 lines
11 KiB
QML
262 lines
11 KiB
QML
// Input & Shortcuts.
|
|
//
|
|
// The shortcut list is generated from `hyprctl binds -j` rather than typed out
|
|
// here. The previous version was a hand-maintained array of nineteen entries
|
|
// against a real keymap of a hundred and thirteen: it could not show the rest,
|
|
// and it went stale the moment a bind changed. Every bind now carries its own
|
|
// description in hypr/keybinds.lua, and this page just groups and renders them.
|
|
//
|
|
// The hardware settings above the list are real controls. Keyboard layout,
|
|
// repeat behavior, and pointer response are Hyprland's, so Panama owns them;
|
|
// device-specific configuration stays with GNOME.
|
|
|
|
import QtQuick
|
|
import qs.config
|
|
import qs.services
|
|
|
|
SettingsPage {
|
|
id: root
|
|
|
|
// The chord of the bind currently being re-recorded, in the Lua form; empty
|
|
// when nothing is being captured. Held here rather than per row so that
|
|
// starting a new capture cancels any other.
|
|
property string capturingChord: ""
|
|
|
|
// The action already holding a chord somebody just pressed, and the chord
|
|
// itself. Held while the capture stays open so the message can name both.
|
|
property string conflict: ""
|
|
property string conflictChord: ""
|
|
readonly property string storedXkbOptions: String(DesktopPreferences.get("keyboardOptions") ?? "")
|
|
|
|
function xkbOptions(): var {
|
|
return root.storedXkbOptions
|
|
.split(",")
|
|
.map(option => option.trim())
|
|
.filter(option => option !== "");
|
|
}
|
|
|
|
function currentXkbOption(prefix: string): string {
|
|
return root.xkbOptions().find(option => option.indexOf(prefix) === 0) ?? "";
|
|
}
|
|
|
|
function setXkbOption(prefix: string, option: string): void {
|
|
const options = root.xkbOptions().filter(option => option.indexOf(prefix) !== 0);
|
|
if (option !== "")
|
|
options.push(option);
|
|
SystemSettings.commitPreference("keyboardOptions", options.join(","));
|
|
}
|
|
|
|
title: "Input & Shortcuts"
|
|
lede: "The Forge mental model, carried forward into native tiling."
|
|
|
|
SettingsCard {
|
|
title: "Keyboard"
|
|
|
|
// 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" }
|
|
|
|
ChoiceGrid {
|
|
width: parent.width
|
|
label: "Caps Lock"
|
|
detail: "Keep it conventional, or turn a prime keyboard position into Escape or Control"
|
|
current: root.currentXkbOption("caps:")
|
|
options: [
|
|
{ value: "", label: "Standard" },
|
|
{ value: "caps:escape_shifted_capslock", label: "Esc · Shift for Caps" },
|
|
{ value: "caps:escape", label: "Escape" },
|
|
{ value: "caps:ctrl_modifier", label: "Control" }
|
|
]
|
|
onPicked: value => root.setXkbOption("caps:", value)
|
|
}
|
|
|
|
ChoiceGrid {
|
|
width: parent.width
|
|
label: "Compose key"
|
|
detail: "Type accented characters and symbols with memorable key sequences"
|
|
current: root.currentXkbOption("compose:")
|
|
options: [
|
|
{ value: "", label: "Off" },
|
|
{ value: "compose:ralt", label: "Right Alt" },
|
|
{ value: "compose:rwin", label: "Right Super" },
|
|
{ value: "compose:menu", label: "Menu" }
|
|
]
|
|
onPicked: value => root.setXkbOption("compose:", value)
|
|
}
|
|
|
|
ChoiceGrid {
|
|
width: parent.width
|
|
label: "Layout switching"
|
|
detail: "Used when Keyboard layout contains more than one comma-separated layout"
|
|
current: root.currentXkbOption("grp:")
|
|
options: [
|
|
{ value: "", label: "Off" },
|
|
{ value: "grp:win_space_toggle", label: "Super + Space" },
|
|
{ value: "grp:alt_shift_toggle", label: "Alt + Shift" },
|
|
{ value: "grp:ctrl_shift_toggle", label: "Ctrl + Shift" },
|
|
{ value: "grp:caps_toggle", label: "Caps Lock" }
|
|
]
|
|
onPicked: value => root.setXkbOption("grp:", value)
|
|
}
|
|
|
|
// Presets preserve every option outside their own category. The raw
|
|
// value remains visible for less common xkeyboard-config features.
|
|
TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" }
|
|
SliderRow { setting: "keyRepeatDelay" }
|
|
SliderRow { setting: "keyRepeatRate" }
|
|
ToggleRow { setting: "numlockByDefault"; divider: false }
|
|
}
|
|
|
|
// Deliberately no handoff to GNOME's keyboard panel here. That panel
|
|
// writes org.gnome.desktop input-source and shortcut gsettings, which
|
|
// nothing in a Hyprland session reads -- so the button looked like the
|
|
// escape hatch for layouts and did nothing, while the controls that DO
|
|
// work sat further up this same page. A handoff to an inert panel is a
|
|
// dead end wearing a button.
|
|
|
|
// One card per group, built from what the compositor actually has bound.
|
|
//
|
|
// Both Repeaters address their model through an explicit id. Inside a
|
|
// SettingsCard the surrounding `parent` is the card's internal Column, not
|
|
// the card, so `parent.modelData` is undefined there and the rows silently
|
|
// never appear -- the cards render with their heading and nothing under it.
|
|
Repeater {
|
|
model: Keybinds.grouped()
|
|
|
|
SettingsCard {
|
|
id: groupCard
|
|
|
|
required property var modelData
|
|
|
|
title: groupCard.modelData.name
|
|
subtitle: groupCard.modelData.binds.length === 1
|
|
? "1 shortcut"
|
|
: `${groupCard.modelData.binds.length} shortcuts`
|
|
|
|
Repeater {
|
|
id: bindRows
|
|
|
|
model: groupCard.modelData.binds
|
|
|
|
SettingRow {
|
|
id: bindRow
|
|
|
|
required property var modelData
|
|
required property int index
|
|
|
|
readonly property bool capturing: root.capturingChord === bindRow.modelData.luaChord
|
|
readonly property bool overridden: Keybinds.isOverridden(bindRow.modelData.luaChord)
|
|
|
|
label: bindRow.modelData.description
|
|
detail: bindRow.overridden
|
|
? "Moved from " + Keybinds.shippedChordFor(bindRow.modelData.luaChord)
|
|
: ""
|
|
controlWidth: 300
|
|
divider: bindRow.index < bindRows.count - 1
|
|
|
|
Item {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
width: 300
|
|
height: 30
|
|
|
|
ShortcutCapture {
|
|
anchors.right: parent.right
|
|
width: 230
|
|
height: 30
|
|
visible: bindRow.capturing
|
|
focus: bindRow.capturing
|
|
message: root.conflict === ""
|
|
? ""
|
|
: root.conflictChord + " is already " + root.conflict
|
|
// A chord already in use is reported rather than
|
|
// taken. Two actions on one chord means whichever
|
|
// Hyprland happens to read last wins, which is not
|
|
// a thing to discover later by pressing it.
|
|
onCaptured: chord => {
|
|
const taken = Keybinds.boundTo(chord, bindRow.modelData.luaChord);
|
|
if (taken !== "") {
|
|
root.conflict = taken;
|
|
root.conflictChord = chord;
|
|
return;
|
|
}
|
|
root.conflict = "";
|
|
Keybinds.rebind(bindRow.modelData.luaChord, chord);
|
|
root.capturingChord = "";
|
|
}
|
|
onCanceled: {
|
|
root.conflict = "";
|
|
root.capturingChord = "";
|
|
}
|
|
}
|
|
|
|
Row {
|
|
anchors.right: parent.right
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: !bindRow.capturing
|
|
spacing: 8
|
|
|
|
Text {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: bindRow.modelData.chord
|
|
color: bindRow.overridden ? Theme.accent : Theme.fgDim
|
|
font.family: Theme.fontFamily
|
|
font.pixelSize: Theme.fontSize
|
|
}
|
|
|
|
SettingsButton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
text: "Change"
|
|
enabled: !Keybinds.reloading && !bindRow.modelData.mouse
|
|
onClicked: root.capturingChord = bindRow.modelData.luaChord
|
|
}
|
|
|
|
SettingsButton {
|
|
anchors.verticalCenter: parent.verticalCenter
|
|
visible: bindRow.overridden
|
|
text: "Reset"
|
|
enabled: !Keybinds.reloading
|
|
onClicked: Keybinds.resetBind(bindRow.modelData.luaChord)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
SettingsCard {
|
|
visible: Object.keys(Keybinds.overrides).length > 0
|
|
title: "Changed shortcuts"
|
|
subtitle: "Rebinding stores only the new chord; what a shortcut does always comes from the desktop's configuration."
|
|
|
|
ActionRow {
|
|
label: "Restore every shipped shortcut"
|
|
detail: Object.keys(Keybinds.overrides).length
|
|
+ (Object.keys(Keybinds.overrides).length === 1 ? " shortcut moved" : " shortcuts moved")
|
|
action: "Restore all"
|
|
divider: false
|
|
enabled: !Keybinds.reloading
|
|
onTriggered: Keybinds.resetAll()
|
|
}
|
|
}
|
|
|
|
SettingsCard {
|
|
visible: Keybinds.lastError !== ""
|
|
title: "Shortcuts unavailable"
|
|
subtitle: Keybinds.lastError
|
|
|
|
ActionRow {
|
|
label: "Read the keymap again"
|
|
detail: "Shortcuts are read from the running compositor"
|
|
action: "Retry"
|
|
divider: false
|
|
onTriggered: Keybinds.refresh()
|
|
}
|
|
}
|
|
}
|