Files

555 lines
22 KiB
QML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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
// ── The shortcuts you invented ──────────────────────────────────────────
// Rendered from the stored `customBinds` array rather than from the
// compositor's report, for two reasons: the stored entry is the only place
// the ACTION is written down (Hyprland reports every Lua bind as "__lua"
// plus a bytecode offset), and a shortcut has to appear the moment it is
// added rather than after the reload settles. The row asks Keybinds whether
// the compositor is actually answering it, so nothing here claims a bind
// that did not take.
property bool adding: false
// The chord of the custom bind being re-recorded, empty when none is.
property string rebindingChord: ""
readonly property var customBinds: {
const needle = root.filter.trim().toLowerCase();
if (needle === "")
return Keybinds.customBinds;
return Keybinds.customBinds.filter(entry =>
String(entry?.label ?? "").toLowerCase().indexOf(needle) >= 0
|| "custom".indexOf(needle) >= 0);
}
function customMessage(): string {
return root.conflict === ""
? ""
: root.conflictChord + " is already " + root.conflict;
}
// 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.
//
// "Custom" is dropped here: those binds are drawn above from the stored
// entries, and showing them twice would read as two shortcuts on one chord.
readonly property var groups: {
const needle = root.filter.trim().toLowerCase();
const out = [];
for (const group of Keybinds.grouped()) {
if (group.name === "Custom")
continue;
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. Your own shortcuts live in the Custom group — none of them stores a command: each names an application, a shell action, or a window move, and Panama resolves the name when you press it."
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: addButton.left
anchors.rightMargin: 12
anchors.verticalCenter: parent.verticalCenter
text: Keybinds.binds.length + " bound · " + root.overrideCount + " changed · "
+ Keybinds.customBinds.length + " custom"
color: Theme.fgMuted
font.family: Theme.fontFamily
font.features: Theme.tabularFigures
font.pixelSize: Theme.fontSizeSmall
}
SettingsButton {
id: addButton
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
tone: "accent"
text: " Add shortcut"
enabled: !root.adding && !Keybinds.reloading
onClicked: {
root.capturingChord = "";
root.rebindingChord = "";
root.conflict = "";
root.conflictChord = "";
addEditor.reset();
root.adding = true;
}
}
}
CustomShortcutEditor {
id: addEditor
width: parent.width
visible: root.adding
onCommitted: (chord, kind, target, label) => {
if (Keybinds.addCustomBind(chord, kind, target, label))
root.adding = false;
}
onCanceled: root.adding = false
}
// The Custom group, pinned above everything the compositor reports.
Column {
width: parent.width
visible: root.customBinds.length > 0
spacing: 0
SectionLabel {
text: "Custom — yours"
count: root.filtering
? "· showing " + root.customBinds.length + " of " + Keybinds.customBinds.length
: "· " + Keybinds.customBinds.length
}
Repeater {
id: customRows
model: root.customBinds
CustomShortcutRow {
id: customRow
required property var modelData
required property int index
entry: customRow.modelData
capturing: root.rebindingChord === String(customRow.modelData.chord ?? "")
message: customRow.capturing ? root.customMessage() : ""
divider: customRow.index < customRows.count - 1
onRebindRequested: {
root.conflict = "";
root.conflictChord = "";
root.capturingChord = "";
root.rebindingChord = String(customRow.modelData.chord ?? "");
}
onRemoveRequested: Keybinds.removeCustomBind(String(customRow.modelData.chord ?? ""))
onCaptured: chord => {
const current = String(customRow.modelData.chord ?? "");
const taken = Keybinds.boundTo(chord, current);
if (taken !== "") {
root.conflict = taken;
root.conflictChord = chord;
return;
}
root.conflict = "";
Keybinds.rebindCustomBind(current, chord);
root.rebindingChord = "";
}
onCanceled: {
root.conflict = "";
root.rebindingChord = "";
}
}
}
}
// 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
SectionLabel {
text: groupColumn.modelData.name
count: root.filtering
? "· showing " + groupColumn.modelData.binds.length
+ " of " + groupColumn.modelData.total
: "· " + groupColumn.modelData.total
}
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.rebindingChord = "";
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 && root.customBinds.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()
}
}
}