Notifications repeated both lock-screen switch labels for every application, so twenty apps meant sixty rows of the same two sentences and the page could not be scanned at all. Each app is one row now, carrying what its switches add up to -- "On, lock screen shows the sender only", "On, hidden on the lock screen", "Notifications off" -- with the switches behind it, one app open at a time. The identifier only appears while an app is open, which is the only time it disambiguates anything, and the content switch dims when the app cannot reach the lock screen at all, because there it means nothing. Shortcuts were already grouped; the problem was that "Windows" caught focus, movement, splitting, resizing and window state alike and held 43 of the 93 binds. A section that long is a list, not a grouping. They are separated by intent now -- Focus, Move & split, Size, Window state -- and the split was checked against the binds this machine actually has rather than trusted from the keywords. Order matters in two places worth naming: "Next window splits down" is about splitting rather than focus, and "Focus session" is quiet mode bound to a workspace rather than window focus, so both are settled before the general checks. Refresh rate gets its own row. That need was created by collapsing the resolution list: the rates for a resolution were only ever reachable by opening it, so changing nothing but the rate meant going through the mode you already had. It appears only when the current resolution offers more than one. Default-application rows carry a chevron, having previously opened a chooser while looking completely inert. The notification contract asserted the literal Notifs.appRule(app.id).enabled, which moved when the rows collapsed. The rule is still read through a binding on Notifs.appRule, so a rule changed elsewhere still reaches the row -- the assertion now requires that, rather than requiring one particular spelling of it. The power profile rows were left alone. A three-way choice in three rows looks wasteful until you notice each row explains what the profile does, and that page has empty space to spare; a segmented control would trade information for space that is not scarce. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
349 lines
13 KiB
QML
349 lines
13 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"]
|
|
|
|
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] }));
|
|
}
|
|
}
|