The sidebar was a flat scan of thirty-one rows; now it reads like a settings app. Multi-subject categories (Input, Network & Sharing, Applications, Users & Accounts, Privacy & Security, System) carry an Appearance-style tab strip above the page, drawn by the shell so the leaf pages themselves are untouched. The taxonomy lives in one new file, services/SettingsRoutes.qml; the sidebar, the strip, route validation, search breadcrumbs, and both generators derive from it. ShellState.settingsPage still holds leaf ids, so every deep link, IPC call, and search result keeps working — and now lands on the exact tab. Dictation moves out of Sound onto its own page under Input, with a handoff back to Sound for the microphone. The strip scrolls when System's nine tabs outgrow a tiled window. All 161 contracts pass. Claude-Session: https://claude.ai/code/session_01Ms2FbjQy31TVf3CEvQhGM8
262 lines
11 KiB
QML
262 lines
11 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.
|
|
//
|
|
// 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: "Keyboard"
|
|
lede: "The Forge mental model, carried forward into native tiling."
|
|
|
|
SettingsCard {
|
|
title: "Layout & 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" }
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|