// Keyboard — layout, key behavior, and every compositor shortcut. // // 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. // // A hundred and thirty rows is a wall, though, and one card per group made the // page a mile long with the interesting part -- what a shortcut is bound to -- // rendered as grey text. So the list is one card with a filter over it, the // chords are drawn as keys, and Change/Reset appear on the row under the // pointer instead of on all hundred and thirty at once. None of the rebinding // machinery moved: Keybinds still owns overrides, conflicts and the reload. // // 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 import qs.modules.clipboard 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: "" // The raw XKB strings are a section rather than two rows in the middle of // the card: they are how the presets above are actually stored, so they // stay one click away rather than being replaced by them. property bool advancedOpen: false readonly property string storedXkbOptions: String(DesktopPreferences.get("keyboardOptions") ?? "") readonly property string storedLayout: String(DesktopPreferences.get("keyboardLayout") ?? "") 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(",")); } // The layouts people actually pick, not the several hundred xkeyboard-config // ships. Anything outside this list still shows -- as its own code, added // below -- and Custom… opens the field that can set one. readonly property var commonLayouts: [ { value: "us", label: "English (US)" }, { value: "gb", label: "English (UK)" }, { value: "de", label: "German" }, { value: "fr", label: "French" }, { value: "es", label: "Spanish" }, { value: "it", label: "Italian" }, { value: "pt", label: "Portuguese" }, { value: "br", label: "Portuguese (Brazil)" }, { value: "se", label: "Swedish" }, { value: "no", label: "Norwegian" }, { value: "dk", label: "Danish" }, { value: "fi", label: "Finnish" }, { value: "nl", label: "Dutch" }, { value: "pl", label: "Polish" }, { value: "cz", label: "Czech" }, { value: "ru", label: "Russian" }, { value: "jp", label: "Japanese" } ] readonly property var layoutOptions: { const options = root.commonLayouts.slice(); // A layout this list does not carry -- "us,de", "dvorak" -- is shown as // the code it is rather than silently reading as English (US). if (root.storedLayout !== "" && !options.some(option => option.value === root.storedLayout)) options.push({ value: root.storedLayout, label: root.storedLayout, detail: "The layout this machine is set to" }); options.push({ value: "__custom", label: "Custom…", detail: "Opens Advanced, where a layout list can be typed in full" }); return options; } readonly property string filter: filterField.text readonly property bool filtering: root.filter.trim() !== "" readonly property int overrideCount: Object.keys(Keybinds.overrides).length // Every group the compositor reports, in Keybinds' own order, narrowed by // the filter. An empty filter narrows nothing: the page's job is to show // the whole keymap, and searching is an extra rather than a gate. readonly property var groups: { const needle = root.filter.trim().toLowerCase(); const out = []; for (const group of Keybinds.grouped()) { const hits = needle === "" ? group.binds : group.binds.filter(bind => String(bind.description).toLowerCase().indexOf(needle) >= 0 || group.name.toLowerCase().indexOf(needle) >= 0); if (hits.length > 0) out.push({ name: group.name, binds: hits, total: group.binds.length }); } return out; } function captureMessage(): string { return root.conflict === "" ? "" : root.conflictChord + " is already " + root.conflict; } title: "Keyboard" lede: "Layout, typing feel, and every shortcut the compositor has bound." SettingsCard { title: "Typing" // 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. OptionPickerRow { label: "Layout" detail: "What the keys produce, before any of the options below" options: root.layoutOptions current: root.storedLayout onPicked: value => { if (value === "__custom") { root.advancedOpen = true; return; } SystemSettings.commitPreference("keyboardLayout", value); } } OptionPickerRow { label: "Caps Lock" detail: "What the Caps Lock key does" options: [ { value: "", label: "Standard", detail: "Caps Lock, as printed on the key" }, { value: "caps:escape_shifted_capslock", label: "Esc · Shift for Caps", detail: "Escape on its own; Shift and Caps Lock together still lock" }, { value: "caps:escape", label: "Escape", detail: "Caps Lock becomes Escape entirely" }, { value: "caps:ctrl_modifier", label: "Control", detail: "A second Control in a prime position" } ] current: root.currentXkbOption("caps:") onPicked: value => root.setXkbOption("caps:", value) } OptionPickerRow { label: "Compose key" detail: "Type accented characters and symbols with memorable key sequences" options: [ { value: "", label: "Off" }, { value: "compose:ralt", label: "Right Alt" }, { value: "compose:rwin", label: "Right Super" }, { value: "compose:menu", label: "Menu" } ] current: root.currentXkbOption("compose:") onPicked: value => root.setXkbOption("compose:", value) } OptionPickerRow { label: "Layout switching" detail: "Used when the layout above contains more than one comma-separated layout" 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" } ] current: root.currentXkbOption("grp:") onPicked: value => root.setXkbOption("grp:", value) } KeyRepeatRow {} ToggleRow { setting: "numlockByDefault"; divider: false } // Presets preserve every option outside their own category, and the raw // value stays here rather than being replaced by them -- xkeyboard-config // has hundreds of options and this card offers four. Item { width: parent.width height: 40 Rectangle { anchors.left: parent.left anchors.right: parent.right anchors.top: parent.top height: 1 color: Theme.alpha(Theme.fg, 0.065) } Row { anchors.left: parent.left anchors.verticalCenter: parent.verticalCenter spacing: 8 Text { anchors.verticalCenter: parent.verticalCenter text: "Advanced" color: Theme.fgDim font.family: Theme.fontFamily font.pixelSize: Theme.fontSize font.weight: Font.Medium } Text { anchors.verticalCenter: parent.verticalCenter text: root.advancedOpen ? "raw XKB options ▴" : "raw XKB options ▾" color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall } } MouseArea { anchors.fill: parent cursorShape: Qt.PointingHandCursor onClicked: root.advancedOpen = !root.advancedOpen } } Column { width: parent.width visible: root.advancedOpen TextEntryRow { setting: "keyboardLayout"; placeholder: "us" } TextEntryRow { setting: "keyboardOptions"; placeholder: "compose:ralt" } TextEntryRow { setting: "keyboardVariant"; placeholder: "none"; 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. SettingsCard { title: "Shortcuts" subtitle: "Click Change and press the new keys. A shortcut another action holds is refused, never stolen." Item { width: parent.width height: 44 SearchField { id: filterField anchors.left: parent.left anchors.right: counts.left anchors.rightMargin: 14 anchors.verticalCenter: parent.verticalCenter placeholder: "Filter shortcuts — try “window” or “volume”" } Text { id: counts anchors.right: parent.right anchors.verticalCenter: parent.verticalCenter text: Keybinds.binds.length + " bound · " + root.overrideCount + " changed" color: Theme.fgMuted font.family: Theme.fontFamily font.features: Theme.tabularFigures font.pixelSize: Theme.fontSizeSmall } } // One Column of Repeaters rather than a Loader per row: at a hundred and // thirty rows the delegates are the page, and the cheapest row is the // one that is simply an Item. Repeater { model: root.groups Column { id: groupColumn required property var modelData width: parent ? parent.width : 620 spacing: 0 Item { width: parent.width height: 30 Row { anchors.left: parent.left anchors.leftMargin: 6 anchors.bottom: parent.bottom anchors.bottomMargin: 4 spacing: 8 Text { anchors.baseline: groupCount.baseline text: groupColumn.modelData.name color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSizeSmall font.weight: Font.DemiBold font.capitalization: Font.AllUppercase font.letterSpacing: 0.8 } Text { id: groupCount text: root.filtering ? "· showing " + groupColumn.modelData.binds.length + " of " + groupColumn.modelData.total : "· " + groupColumn.modelData.total color: Theme.fgMuted font.family: Theme.fontFamily font.features: Theme.tabularFigures font.pixelSize: Theme.fontSizeSmall } } } Repeater { id: bindRows model: groupColumn.modelData.binds ShortcutRow { id: shortcutRow required property var modelData required property int index bind: shortcutRow.modelData capturing: root.capturingChord === shortcutRow.modelData.luaChord message: shortcutRow.capturing ? root.captureMessage() : "" divider: shortcutRow.index < bindRows.count - 1 onChangeRequested: { root.conflict = ""; root.conflictChord = ""; root.capturingChord = shortcutRow.modelData.luaChord; } onResetRequested: Keybinds.resetBind(shortcutRow.modelData.luaChord) // 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, shortcutRow.modelData.luaChord); if (taken !== "") { root.conflict = taken; root.conflictChord = chord; return; } root.conflict = ""; Keybinds.rebind(shortcutRow.modelData.luaChord, chord); root.capturingChord = ""; } onCanceled: { root.conflict = ""; root.capturingChord = ""; } } } } } Text { width: parent.width visible: root.groups.length === 0 && Keybinds.loaded text: root.filtering ? "Nothing matches — the filter searches shortcut names and group names." : "The compositor reported no shortcuts." color: Theme.fgMuted font.family: Theme.fontFamily font.pixelSize: Theme.fontSize topPadding: 16 bottomPadding: 16 } // Rebinding stores only the new chord; what a shortcut does always // comes from the desktop's configuration. ActionRow { label: "Restore every shipped shortcut" detail: root.overrideCount === 0 ? "Every shortcut is where it shipped" : root.overrideCount + (root.overrideCount === 1 ? " shortcut differs from the shipped keymap" : " shortcuts differ from the shipped keymap") action: "Restore all" divider: false enabled: root.overrideCount > 0 && !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() } } }