Files
Panama/config/dot/quickshell/services/Keybinds.qml
T
Gabriel Brown 79b3d5cb85 Close the sweep's last blind spot, and stop shortcuts silently colliding
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
2026-08-20 09:56:02 -04:00

364 lines
14 KiB
QML

pragma Singleton
// The keymap, read from the compositor rather than restated.
//
// The Shortcuts page used to hold a hand-typed array of nineteen entries while
// keybinds.lua produced a hundred and thirteen. It could not show the other
// ninety-four, and it drifted the moment a bind was edited. `hyprctl binds -j`
// is the only description of the keymap that cannot be wrong, so this reads
// that and every bind carries its own human label (see the `description`
// argument in hypr/keybinds.lua).
//
// Refreshed on demand, not polled: binds only change when the config is
// reloaded, and nothing in this shell should wake up to re-read something that
// has not moved.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
// [{ chord, description, group, mouse, repeating, locked }], ordered as
// Hyprland reports them, which follows the order they appear in the config.
property var binds: []
property bool loaded: false
property string lastError: ""
readonly property bool busy: query.running
// Hyprland's modmask bits. SUPER is the Panama modifier.
readonly property var modifierBits: [
{ bit: 64, name: "Super" },
{ bit: 4, name: "Ctrl" },
{ bit: 8, name: "Alt" },
{ bit: 1, name: "Shift" }
]
// Keysyms whose raw names would be noise in a shortcuts list.
readonly property var keyNames: ({
"mouse_up": "Scroll up",
"mouse_down": "Scroll down",
"mouse:272": "Left click",
"mouse:273": "Right click",
"mouse:274": "Middle click",
"bracketleft": "[",
"bracketright": "]",
"grave": "`",
"Print": "Print Screen",
"left": "←",
"right": "→",
"up": "↑",
"down": "↓"
})
Process {
id: query
command: ["hyprctl", "-j", "binds"]
stdout: StdioCollector {
onStreamFinished: root.parse(this.text)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "Could not read the keymap from Hyprland.";
}
}
// ── Rebinding ───────────────────────────────────────────────────────────
// Overrides map a SHIPPED chord to a replacement. hypr/keybinds.lua reads
// them and substitutes only the chord -- the action is always the Lua value
// written in that file -- so an override can move a shortcut but can never
// make one do something else.
//
// Applying needs `hyprctl reload` rather than a live `hl.bind`: Hyprland
// reports Lua-defined binds with dispatcher "__lua" and a bytecode offset,
// so the action cannot be reconstructed from the outside to re-bind it.
// Reload re-runs the config, which re-reads the settings file.
readonly property var overrides: {
const stored = DesktopPreferences.get("keybindOverrides");
return (stored && typeof stored === "object") ? stored : ({});
}
property bool reloading: false
// The chord a bind ships with, given the chord it currently answers to.
// Displayed binds come from the compositor and so already reflect any
// override; the override map is what tells us where they started.
function shippedChordFor(currentChord: string): string {
for (const shipped in root.overrides) {
if (root.overrides[shipped] === currentChord)
return shipped;
}
return currentChord;
}
function isOverridden(currentChord: string): bool {
return root.shippedChordFor(currentChord) !== currentChord;
}
// Refuses a chord already answering to something else, so rebinding cannot
// quietly shadow an existing shortcut.
function conflictFor(chord: string, exceptCurrent: string): string {
for (const bind of root.binds) {
if (bind.luaChord === chord && bind.luaChord !== exceptCurrent)
return bind.description;
}
return "";
}
// A moved shortcut vacates its shipped chord, so another override may use
// it legitimately. Refuse to reset the first shortcut until that occupant
// moves away; otherwise Hyprland would receive two binds on one chord.
function overrideOccupantFor(chord: string, exceptShipped: string): string {
for (const shipped in root.overrides) {
if (shipped !== exceptShipped && root.overrides[shipped] === chord)
return shipped;
}
return "";
}
function rebind(currentChord: string, newChord: string): bool {
if (newChord === "" || newChord === currentChord)
return false;
const conflict = root.conflictFor(newChord, currentChord);
if (conflict !== "") {
root.lastError = `${newChord} is already ${conflict}.`;
return false;
}
const shipped = root.shippedChordFor(currentChord);
const next = Object.assign({}, root.overrides);
if (newChord === shipped)
delete next[shipped];
else
next[shipped] = newChord;
if (!DesktopPreferences.set("keybindOverrides", next)) {
root.lastError = "That shortcut could not be saved.";
return false;
}
root.applyReload();
return true;
}
function resetBind(currentChord: string): bool {
const shipped = root.shippedChordFor(currentChord);
if (shipped === currentChord)
return true;
const occupant = root.overrideOccupantFor(shipped, shipped);
if (occupant !== "") {
root.lastError = `${shipped} is used by another rebound shortcut. Reset that shortcut first.`;
return false;
}
const next = Object.assign({}, root.overrides);
delete next[shipped];
if (!DesktopPreferences.set("keybindOverrides", next)) {
root.lastError = "That shortcut could not be reset.";
return false;
}
root.applyReload();
return true;
}
function resetAll(): void {
if (Object.keys(root.overrides).length === 0)
return;
DesktopPreferences.set("keybindOverrides", ({}));
root.applyReload();
}
Process {
id: reloadRun
command: ["hyprctl", "reload"]
onExited: (exitCode, exitStatus) => {
root.reloading = false;
if (exitCode !== 0) {
root.lastError = "The compositor did not reload.";
return;
}
root.lastError = "";
// The settings file is written on a timer, so re-read the keymap
// once the reload has had a moment to pick it up.
settle.restart();
}
}
Timer {
id: settle
interval: 350
onTriggered: root.refresh()
}
function applyReload(): void {
if (reloadRun.running)
return;
root.reloading = true;
// Give DesktopPreferences' coalescing write a moment to land first.
reloadDelay.restart();
}
Timer {
id: reloadDelay
interval: 120
onTriggered: reloadRun.running = true
}
Component.onCompleted: root.refresh()
function refresh(): void {
if (query.running)
return;
root.lastError = "";
query.running = true;
}
function parse(text: string): void {
try {
const raw = JSON.parse(text);
const out = [];
for (const bind of raw) {
const description = String(bind.description ?? "").trim();
// A bind with no description cannot be presented usefully --
// the dispatcher is "__lua" and the argument is a bytecode
// offset. Showing the chord alone would be worse than omitting
// it, and tests/quickshell/keybinds-contract.sh fails the build
// if any exist, so this should never be reached in practice.
if (description === "")
continue;
out.push({
chord: root.formatChord(bind),
// The same chord in the form hypr/keybinds.lua writes, which
// is what an override is keyed by. The display form
// prettifies modifiers and arrow keys and so cannot be used
// for that.
luaChord: root.luaChord(bind),
description: description,
group: root.groupFor(description, bind),
mouse: bind.mouse === true,
repeating: bind.repeat === true,
locked: bind.locked === true
});
}
root.binds = out;
root.loaded = true;
root.lastError = "";
} catch (error) {
root.lastError = "The keymap could not be read.";
}
}
// "SUPER + SHIFT + K" -- uppercase modifiers in the order keybinds.lua
// writes them, then the raw keysym rather than its display name.
function luaChord(bind: var): string {
const parts = [];
for (const modifier of root.modifierBits) {
if ((bind.modmask & modifier.bit) !== 0)
parts.push(modifier.name.toUpperCase());
}
parts.push(String(bind.key ?? ""));
return parts.join(" + ");
}
function formatChord(bind: var): string {
const parts = [];
for (const modifier of root.modifierBits) {
if ((bind.modmask & modifier.bit) !== 0)
parts.push(modifier.name);
}
const key = String(bind.key ?? "");
parts.push(root.keyNames[key] ?? (key.length === 1 ? key.toUpperCase() : key));
return parts.join(" + ");
}
// Grouping is by what the shortcut does, taken from its own description,
// so adding a bind puts it in the right section without touching this file.
// Which section a bind belongs to.
//
// "Windows" used to catch focus, movement, splitting, resizing and window
// state alike, which put 43 of the 93 binds under one heading -- a section
// that long is a list, not a grouping. The window verbs are separated here
// by what you are actually trying to do.
//
// Order matters: "Next window splits down" is about splitting rather than
// focus, and "Focus session" is a Panama feature rather than window focus,
// so both are settled before the general checks below them.
function groupFor(description: string, bind: var): string {
const text = description.toLowerCase();
if (bind.key && String(bind.key).indexOf("XF86") === 0)
return "Media & hardware keys";
// Quiet mode and Caffeine bound to a workspace, not window focus.
if (text.indexOf("focus session") >= 0)
return "Applications & shell";
if (text.indexOf("workspace") >= 0)
return "Workspaces";
if (text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0
|| text.indexOf("taller") >= 0 || text.indexOf("shorter") >= 0
|| text.indexOf("shrink") >= 0 || text.indexOf("expand") >= 0
|| text.indexOf("grow") >= 0 || text.indexOf("resize") >= 0)
return "Size";
if (text.indexOf("split") >= 0 || text.indexOf("swap") >= 0
|| text.indexOf("move window") >= 0)
return "Move & split";
if (text.indexOf("close") >= 0 || text.indexOf("fullscreen") >= 0
|| text.indexOf("float") >= 0 || text.indexOf("pin ") >= 0
|| text.indexOf("scratchpad") >= 0 || text.indexOf("minimize") >= 0)
return "Window state";
if (text.indexOf("focus") >= 0 || text.indexOf("next window") >= 0
|| text.indexOf("previous window") >= 0 || text.indexOf("last window") >= 0
|| text.indexOf("window switch") >= 0)
return "Focus";
if (text.indexOf("volume") >= 0 || text.indexOf("mute") >= 0
|| text.indexOf("track") >= 0 || text.indexOf("play") >= 0
|| text.indexOf("brightness") >= 0)
return "Media & hardware keys";
return "Applications & shell";
}
// Section order for the page. Anything a future bind invents lands at the
// end rather than being dropped.
readonly property var groupOrder: ["Focus", "Move & split", "Size", "Window state",
"Workspaces", "Applications & shell", "Media & hardware keys"]
// The action already bound to a chord, or "" if it is free. Compared on the
// form keybinds.lua writes rather than the prettified display form, because
// that is what a rebind is keyed by -- "SUPER + Q" and "Super+Q" are the
// same binding and must not read as two.
function boundTo(luaChord: string, exceptLuaChord: string): string {
const wanted = String(luaChord).replace(/\s+/g, "").toLowerCase();
const skip = String(exceptLuaChord).replace(/\s+/g, "").toLowerCase();
for (const bind of root.binds) {
const candidate = String(bind.luaChord).replace(/\s+/g, "").toLowerCase();
if (candidate === wanted && candidate !== skip)
return String(bind.description);
}
return "";
}
function grouped(): var {
const buckets = {};
for (const bind of root.binds) {
buckets[bind.group] = buckets[bind.group] ?? [];
buckets[bind.group].push(bind);
}
const names = Object.keys(buckets).sort((a, b) => {
const ia = root.groupOrder.indexOf(a);
const ib = root.groupOrder.indexOf(b);
return (ia < 0 ? 999 : ia) - (ib < 0 ? 999 : ib);
});
return names.map(name => ({ name: name, binds: buckets[name] }));
}
}