gapsIn and gapsOut were the only two compositor settings the write sweep had never verified: Hyprland answers for them in CSS shorthand, "5 5 5 5", and the sweep had no way to compare that. The preference behind each is a single int that Hyprland expands to four sides, so a uniform reading compares exactly. A non-uniform one is not something the preference can express, and is skipped rather than collapsed to a number it never wrote. 63 of 63 verified live now, none skipped. Wallpaper thumbnails are cached. The report that five of them sat at "Loading…" was a screenshot taken 1.1 seconds after the page opened -- decoding one of these at tile size takes between 1.2 and 2.6 seconds and about ten start at once, which the code already said. Measuring it did turn up something real though: without a cache, scrolling back up pays that decode again for every tile. The tradeoff is a wallpaper replaced in place showing a stale thumbnail until restart, which is worth it for a directory of files that are added rather than edited. A chord already in use is now named rather than taken: "Super+Q is already Terminal". Two actions on one chord means whichever Hyprland reads last wins, which is not a thing to find out later by pressing it. Rebinding a shortcut to the chord it already holds is correctly not a conflict. Also: Open Appearance lands on the Windows tab now that the page has tabs, Storage points at reclaimable container space, and a dock row shows its desktop id only when two pinned applications share a name -- it is developer text, and repeating it under fifteen recognisable names made the list harder to scan. Written down because it cost the shell: QML has no default parameter values, and `function openSettings(page: string, section: string = "")` fails the entire configuration rather than the one function -- so the bar and dock went with it, and 43 contracts failed at once pointing at the same line. qmllint --bare passes that, which is why the usual check before touching the running shell did not catch it. openSettingsSection exists as a separate function for that reason. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
267 lines
11 KiB
QML
267 lines
11 KiB
QML
// Input & Shortcuts.
|
|
//
|
|
// 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: "Input & Shortcuts"
|
|
lede: "The Forge mental model, carried forward into native tiling."
|
|
|
|
SettingsCard {
|
|
title: "Keyboard"
|
|
|
|
// 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 }
|
|
}
|
|
|
|
SettingsCard {
|
|
title: "Hardware input"
|
|
|
|
ActionRow {
|
|
label: "Mouse, touchpad, and keyboard devices"
|
|
detail: "Device-specific settings stay with Fedora's hardware-backed panels"
|
|
action: "Open keyboard"
|
|
divider: false
|
|
onTriggered: SystemSettings.openGnomePanel("keyboard")
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
}
|