Build the settings vocabulary and generate the keymap
Stage 3 and 4 of docs/superpowers/plans/2026-08-17-panama-cohesion.md. Add SettingsPage plus ToggleRow, SliderRow, ChoiceRow, ActionRow, and TextRow. A row names a schema key and needs nothing else: label, detail, range, and unit come from PreferenceSchema, and writes go through SystemSettings.commitPreference, which routes compositor-backed keys through apply-and-verify and local keys straight to the store. The page scaffold that was copy-pasted eleven times is now one component. Rebuild Appearance around a live preview of the real desktop, scaled by the ratio between the preview and the actual monitor so a 10px gap on a 4500px display looks as small as it is. Rebuild Desktop & Dock and Input & Shortcuts on the shared rows, replacing the read-only text that stood in for controls that were merely expensive to add. Generate the shortcut list from hyprctl binds. The page held a hand-typed nineteen entries against a real keymap of a hundred and thirteen; it could not show the rest and went stale whenever a bind changed. Every bind now carries its own description -- backfilled for the twenty-nine that lacked one -- and keybinds-contract.sh fails if any bind lacks one, since undescribed binds are dropped from the page. Make Restore defaults span every store Panama owns. Resetting only the schema store left the Home accessory arrangement customised while claiming to restore defaults, which is worse than no reset because it is silent. Done through HomePreferences' existing public aliases rather than a new API. Four defects found while building: cursor:inactive_timeout is answered by getoption as float, not int. A wrong readAs does not fail loudly; it makes every write to that key look rejected, and the user saw an error for a change that worked. schema-hypr-shape-contract.sh now checks all 23 mapped options against the running compositor. The Settings window is tiled, so implicitWidth is only a hint and rows must survive roughly 400px. SliderRow stacks its control under the label below 520px. Binding an anchor to undefined to switch layouts does not reliably release it. Both row layouts are positioned explicitly. Concurrent compositor writes are queued and merged rather than refused. The startup replay of every compositor-backed preference routinely overlaps a UI change, and refusing left the store and the compositor disagreeing. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -0,0 +1,158 @@
|
||||
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
|
||||
|
||||
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.";
|
||||
}
|
||||
}
|
||||
|
||||
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),
|
||||
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.";
|
||||
}
|
||||
}
|
||||
|
||||
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.
|
||||
function groupFor(description: string, bind: var): string {
|
||||
const text = description.toLowerCase();
|
||||
if (bind.key && String(bind.key).indexOf("XF86") === 0)
|
||||
return "Media & hardware keys";
|
||||
if (text.indexOf("workspace") >= 0)
|
||||
return "Workspaces";
|
||||
if (text.indexOf("window") >= 0 || text.indexOf("focus") >= 0
|
||||
|| text.indexOf("swap") >= 0 || text.indexOf("split") >= 0
|
||||
|| text.indexOf("wider") >= 0 || text.indexOf("narrower") >= 0
|
||||
|| text.indexOf("taller") >= 0 || text.indexOf("shorter") >= 0
|
||||
|| text.indexOf("shrink") >= 0 || text.indexOf("grow") >= 0
|
||||
|| text.indexOf("float") >= 0 || text.indexOf("fullscreen") >= 0
|
||||
|| text.indexOf("close") >= 0 || text.indexOf("scratchpad") >= 0)
|
||||
return "Windows";
|
||||
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: ["Windows", "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] }));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user