Files
Panama/config/dot/quickshell/modules/settings/ShortcutsPage.qml
T

457 lines
18 KiB
QML

// 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.
//
// A SettingRow with its own ConfirmAction rather than an ActionRow:
// this one press drops every override at once and reloads the live
// compositor, so it takes two, and the armed detail says both.
SettingRow {
label: "Restore every shipped shortcut"
detail: restoreConfirm.armed
? "Puts " + root.overrideCount + (root.overrideCount === 1
? " changed shortcut" : " changed shortcuts")
+ " back where they shipped and reloads the compositor now"
: (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"))
controlWidth: restoreConfirm.armed ? 220 : 130
divider: false
ConfirmAction {
id: restoreConfirm
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
actionId: "keybinds-restore-all"
armText: "Restore all…"
confirmText: "Restore them"
enabled: root.overrideCount > 0 && !Keybinds.reloading
onConfirmed: 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()
}
}
}