Give Input keycaps, a shortcut search, and the missing pointer basics
Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
This commit is contained in:
@@ -6,6 +6,13 @@
|
||||
// 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.
|
||||
@@ -13,6 +20,7 @@
|
||||
import QtQuick
|
||||
import qs.config
|
||||
import qs.services
|
||||
import qs.modules.clipboard
|
||||
|
||||
SettingsPage {
|
||||
id: root
|
||||
@@ -26,7 +34,14 @@ SettingsPage {
|
||||
// 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
|
||||
@@ -46,53 +61,130 @@ SettingsPage {
|
||||
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: "The Forge mental model, carried forward into native tiling."
|
||||
lede: "Layout, typing feel, and every shortcut the compositor has bound."
|
||||
|
||||
SettingsCard {
|
||||
title: "Layout & typing"
|
||||
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.
|
||||
TextEntryRow { setting: "keyboardLayout"; placeholder: "us" }
|
||||
TextEntryRow { setting: "keyboardVariant"; placeholder: "none" }
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
OptionPickerRow {
|
||||
label: "Caps Lock"
|
||||
detail: "Keep it conventional, or turn a prime keyboard position into Escape or Control"
|
||||
current: root.currentXkbOption("caps:")
|
||||
detail: "What the Caps Lock key does"
|
||||
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" }
|
||||
{ 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)
|
||||
}
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
OptionPickerRow {
|
||||
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" }
|
||||
]
|
||||
current: root.currentXkbOption("compose:")
|
||||
onPicked: value => root.setXkbOption("compose:", value)
|
||||
}
|
||||
|
||||
ChoiceGrid {
|
||||
width: parent.width
|
||||
OptionPickerRow {
|
||||
label: "Layout switching"
|
||||
detail: "Used when Keyboard layout contains more than one comma-separated layout"
|
||||
current: root.currentXkbOption("grp:")
|
||||
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" },
|
||||
@@ -100,15 +192,67 @@ SettingsPage {
|
||||
{ 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)
|
||||
}
|
||||
|
||||
// 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" }
|
||||
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
|
||||
@@ -118,129 +262,162 @@ SettingsPage {
|
||||
// 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 {
|
||||
title: "Shortcuts"
|
||||
subtitle: "Click Change and press the new keys. A shortcut another action holds is refused, never stolen."
|
||||
|
||||
SettingsCard {
|
||||
id: groupCard
|
||||
Item {
|
||||
width: parent.width
|
||||
height: 44
|
||||
|
||||
required property var modelData
|
||||
SearchField {
|
||||
id: filterField
|
||||
|
||||
title: groupCard.modelData.name
|
||||
subtitle: groupCard.modelData.binds.length === 1
|
||||
? "1 shortcut"
|
||||
: `${groupCard.modelData.binds.length} shortcuts`
|
||||
anchors.left: parent.left
|
||||
anchors.right: counts.left
|
||||
anchors.rightMargin: 14
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
placeholder: "Filter shortcuts — try “window” or “volume”"
|
||||
}
|
||||
|
||||
Repeater {
|
||||
id: bindRows
|
||||
Text {
|
||||
id: counts
|
||||
|
||||
model: groupCard.modelData.binds
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
SettingRow {
|
||||
id: bindRow
|
||||
// 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
|
||||
|
||||
required property var modelData
|
||||
required property int index
|
||||
Column {
|
||||
id: groupColumn
|
||||
|
||||
readonly property bool capturing: root.capturingChord === bindRow.modelData.luaChord
|
||||
readonly property bool overridden: Keybinds.isOverridden(bindRow.modelData.luaChord)
|
||||
required property var modelData
|
||||
|
||||
label: bindRow.modelData.description
|
||||
detail: bindRow.overridden
|
||||
? "Moved from " + Keybinds.shippedChordFor(bindRow.modelData.luaChord)
|
||||
: ""
|
||||
controlWidth: 300
|
||||
divider: bindRow.index < bindRows.count - 1
|
||||
width: parent ? parent.width : 620
|
||||
spacing: 0
|
||||
|
||||
Item {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
width: 300
|
||||
height: 30
|
||||
Item {
|
||||
width: parent.width
|
||||
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.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
|
||||
}
|
||||
|
||||
Row {
|
||||
anchors.right: parent.right
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: !bindRow.capturing
|
||||
spacing: 8
|
||||
Text {
|
||||
id: groupCount
|
||||
|
||||
Text {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: bindRow.modelData.chord
|
||||
color: bindRow.overridden ? Theme.accent : Theme.fgDim
|
||||
font.family: Theme.fontFamily
|
||||
font.pixelSize: Theme.fontSize
|
||||
}
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
text: "Change"
|
||||
enabled: !Keybinds.reloading && !bindRow.modelData.mouse
|
||||
onClicked: root.capturingChord = bindRow.modelData.luaChord
|
||||
}
|
||||
Repeater {
|
||||
id: bindRows
|
||||
|
||||
SettingsButton {
|
||||
anchors.verticalCenter: parent.verticalCenter
|
||||
visible: bindRow.overridden
|
||||
text: "Reset"
|
||||
enabled: !Keybinds.reloading
|
||||
onClicked: Keybinds.resetBind(bindRow.modelData.luaChord)
|
||||
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 = "";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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."
|
||||
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: Object.keys(Keybinds.overrides).length
|
||||
+ (Object.keys(Keybinds.overrides).length === 1 ? " shortcut moved" : " shortcuts moved")
|
||||
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: !Keybinds.reloading
|
||||
enabled: root.overrideCount > 0 && !Keybinds.reloading
|
||||
onTriggered: Keybinds.resetAll()
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user