Files
Panama/config/dot/quickshell/services/SystemSettings.qml
T
Gabriel Brown fe7c85e471 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
2026-08-18 00:24:51 -04:00

473 lines
19 KiB
QML

pragma Singleton
// The allow-listed machine boundary for Panama Settings. Visual pages call
// these methods; no UI text is ever interpolated into a shell command.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
property string monitorName: ""
property string monitorDescription: ""
property int monitorWidth: 0
property int monitorHeight: 0
property real monitorRefreshRate: 0
property real monitorScale: 1
property string monitorFormat: ""
property string colorPreset: ""
property bool monitorVrrActive: false
property bool nextcloudActive: false
property bool rustdeskActive: false
property bool kdeconnectActive: false
property bool hyprpaperActive: false
property bool hypridleActive: false
property bool vicinaeActive: false
property bool bluebubblesDetected: false
property string hyprlandVersion: ""
property string quickshellVersion: "0.3.0"
property string lastError: ""
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|| configWrite.running || configVerify.running || bluebubblesQuery.running
readonly property bool bluebubblesAvailable: root.bluebubblesDetected
readonly property bool autoHdr: DesktopPreferences.get("autoHdr")
readonly property int vrrPolicy: DesktopPreferences.get("vrrPolicy")
readonly property int directScanoutPolicy: DesktopPreferences.get("directScanoutPolicy")
// ── The Hyprland write boundary ─────────────────────────────────────────
// Every option Panama may write, with the hl.config path used to set it and
// the getoption path used to read it back. The UI never names an option or
// supplies a raw value: it calls a setter, which resolves the option here
// and range-checks the value against `allowed`. Nothing user-supplied is
// ever interpolated into the payload.
//
// `hyprctl keyword` is deliberately NOT used. On a Lua-configured Hyprland
// it refuses the write, prints "keyword can't work with non-legacy parsers"
// to stdout, and still exits 0 -- so code branching on the exit status
// believes it succeeded. `hyprctl eval` has the same hazard: it exits 0 on
// syntax and runtime errors, reporting them as an "error:" line instead.
//
// Success therefore means exactly one thing here: the value was read back
// from the compositor and matched what was requested.
//
// The set of writable options is not restated here: it is every schema
// entry carrying a `hypr` block. Adding a live-adjustable Hyprland setting
// is a schema entry plus a prefs.get() call in the Lua, and needs no new
// code in this file.
Process {
id: monitorQuery
command: ["hyprctl", "-j", "monitors"]
stdout: StdioCollector {
onStreamFinished: root.parseMonitors(this.text)
}
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "Could not read the active display.";
}
}
Process {
id: serviceQuery
command: [
"bash", "-lc",
"printf '{\"nextcloud\":%s,\"rustdesk\":%s,\"kdeconnect\":%s,\"hyprpaper\":%s,\"hypridle\":%s,\"vicinae\":%s}\\n' "
+ "$(pgrep -x nextcloud >/dev/null && printf true || printf false) "
+ "$(systemctl is-active --quiet rustdesk.service && printf true || printf false) "
+ "$(pgrep -x kdeconnectd >/dev/null && printf true || printf false) "
+ "$(systemctl --user is-active --quiet hyprpaper.service && printf true || printf false) "
+ "$(systemctl --user is-active --quiet hypridle.service && printf true || printf false) "
+ "$(systemctl --user is-active --quiet vicinae.service && printf true || printf false)"
]
stdout: StdioCollector {
onStreamFinished: root.parseServices(this.text)
}
}
Process {
id: versionQuery
command: ["Hyprland", "--version"]
stdout: StdioCollector {
onStreamFinished: {
const match = this.text.match(/Hyprland\s+([0-9.]+)/);
root.hyprlandVersion = match ? match[1] : this.text.trim().split("\n")[0];
}
}
}
// Applies a validated batch of options in one `hl.config{}` call, then hands
// off to configVerify. Never commits anything on its own: an "ok" here only
// means Hyprland parsed the payload.
Process {
id: configWrite
// id -> integer value, already validated by applyOptions().
property var pending: ({})
stdout: StdioCollector {
onStreamFinished: {
if (this.text.indexOf("error:") >= 0) {
root.reportWriteFailure(configWrite.pending, this.text);
return;
}
root.verifyPending();
}
}
}
Process {
id: bluebubblesQuery
command: ["flatpak", "info", "app.bluebubbles.BlueBubbles"]
onExited: (exitCode, exitStatus) => root.bluebubblesDetected = exitCode === 0
}
// Reads the written options back out of the compositor. This is the only
// thing that decides whether a write succeeded.
Process {
id: configVerify
stdout: StdioCollector {
onStreamFinished: root.commitVerified(configWrite.pending, this.text)
}
}
Timer {
// Preferences load asynchronously from disk. Applying after one quiet
// second avoids racing their restore and runs only once per shell start.
interval: 1000
running: true
onTriggered: root.applyPersistedDisplayPolicy()
}
Component.onCompleted: root.refresh()
function refresh(): void {
root.lastError = "";
if (!monitorQuery.running)
monitorQuery.running = true;
if (!serviceQuery.running)
serviceQuery.running = true;
if (!versionQuery.running && !root.hyprlandVersion)
versionQuery.running = true;
if (!bluebubblesQuery.running)
bluebubblesQuery.running = true;
}
function parseMonitors(text: string): void {
try {
const monitors = JSON.parse(text);
const monitor = monitors.find(item => item.focused) ?? monitors[0];
if (!monitor)
throw new Error("No active monitor");
root.monitorName = monitor.name ?? "";
root.monitorDescription = monitor.description ?? monitor.model ?? "Display";
root.monitorWidth = monitor.width ?? 0;
root.monitorHeight = monitor.height ?? 0;
root.monitorRefreshRate = monitor.refreshRate ?? 0;
root.monitorScale = monitor.scale ?? 1;
root.monitorFormat = monitor.currentFormat ?? "";
root.colorPreset = monitor.colorManagementPreset ?? "";
root.monitorVrrActive = monitor.vrr ?? false;
} catch (error) {
root.lastError = "The display response could not be read.";
}
}
function parseServices(text: string): void {
try {
const state = JSON.parse(text);
root.nextcloudActive = state.nextcloud === true;
root.rustdeskActive = state.rustdesk === true;
root.kdeconnectActive = state.kdeconnect === true;
root.hyprpaperActive = state.hyprpaper === true;
root.hypridleActive = state.hypridle === true;
root.vicinaeActive = state.vicinae === true;
} catch (error) {
root.lastError = "Startup-service status could not be read.";
}
}
// ── Applying options ────────────────────────────────────────────────────
// `values` maps schema keys to values, e.g. { vrrPolicy: 3, gapsOut: 12 }.
// The whole batch is validated before anything is sent, so one bad value
// rejects the batch rather than half-applying it.
function applyOptions(values: var): bool {
const requested = {};
for (const key in values) {
const entry = PreferenceSchema.spec(key);
if (!entry || !entry.hypr) {
root.lastError = "That setting is not applied by the compositor.";
return false;
}
const coerced = PreferenceSchema.coerce(key, values[key]);
if (coerced === undefined) {
root.lastError = `Unsupported value for ${entry.label}.`;
return false;
}
requested[key] = coerced;
}
if (Object.keys(requested).length === 0)
return false;
// A write in flight is queued rather than refused. Options are applied
// and verified one batch at a time, but the callers are a settings UI
// and a startup replay of every compositor-backed preference -- they
// overlap routinely, and dropping a change on the floor would leave the
// stored value and the compositor disagreeing. Later values for the
// same key win.
if (configWrite.running || configVerify.running) {
root.queued = Object.assign({}, root.queued, requested);
return true;
}
root.startWrite(requested);
return true;
}
// Merged batches waiting for the current write to finish.
property var queued: ({})
function startWrite(requested: var): void {
configWrite.pending = requested;
configWrite.exec(["hyprctl", "eval", root.buildConfigPayload(requested)]);
}
// Called when a write settles, however it settled. A failed batch must not
// strand whatever queued up behind it.
function drainQueue(): void {
const next = root.queued;
if (Object.keys(next).length === 0)
return;
root.queued = ({});
root.startWrite(next);
}
// The value as Hyprland stores it. Several options are a toggle in the UI
// but an integer in the compositor (cm_auto_hdr, follow_mouse); `readAs`
// decides, and config/dot/hypr/prefs.lua does the same conversion via
// prefs.getInt so both sides agree.
function hyprValue(entry: var, value: var): var {
if (typeof value === "boolean" && entry.hypr.readAs !== "bool")
return value ? 1 : 0;
return value;
}
// Serialises validated values into a nested hl.config{} call. Table paths
// come from the schema and values have already passed coerce(), including
// the pattern check on constrained strings, so nothing caller-supplied
// reaches the payload unchecked.
function buildConfigPayload(requested: var): string {
const tree = {};
for (const key in requested) {
const entry = PreferenceSchema.spec(key);
const path = entry.hypr.path;
let node = tree;
for (let i = 0; i < path.length - 1; i++)
node = node[path[i]] = node[path[i]] ?? {};
node[path[path.length - 1]] = root.serialiseValue(root.hyprValue(entry, requested[key]));
}
return `hl.config(${root.serialiseTable(tree)})`;
}
function serialiseValue(value: var): string {
if (typeof value === "boolean")
return value ? "true" : "false";
if (typeof value === "number")
return String(value);
// Strings only reach here after the schema's pattern check; quoting is
// belt-and-braces rather than the primary defence.
return `"${String(value).replace(/["\\]/g, "")}"`;
}
function serialiseTable(node: var): string {
const parts = [];
for (const name in node) {
const child = node[name];
parts.push(`${name} = ${typeof child === "string" ? child : root.serialiseTable(child)}`);
}
return `{ ${parts.join(", ")} }`;
}
function verifyPending(): void {
const options = Object.keys(configWrite.pending)
.map(key => `getoption ${PreferenceSchema.spec(key).hypr.option}`)
.join(" ; ");
configVerify.exec(["hyprctl", "-j", "--batch", options]);
}
// The compositor's answer is authoritative. Preferences are only updated for
// options that actually read back with the requested value.
function commitVerified(requested: var, text: string): void {
// Each getoption answers with its own flat JSON object, and the field
// carrying the value depends on the option's type -- int, bool, float,
// str, or css for the gap box.
const observed = {};
for (const block of text.match(/\{[^{}]*\}/g) ?? []) {
try {
const parsed = JSON.parse(block);
if (parsed.option !== undefined)
observed[parsed.option] = parsed;
} catch (error) {
// A partial line is treated as "not observed", which fails the
// comparison below rather than being mistaken for success.
}
}
const rejected = [];
for (const key in requested) {
const entry = PreferenceSchema.spec(key);
if (!root.matchesObserved(entry, requested[key], observed[entry.hypr.option])) {
rejected.push(entry.label);
continue;
}
DesktopPreferences.set(key, requested[key]);
}
root.lastError = rejected.length === 0 ? "" : `Hyprland did not apply ${rejected.join(" or ")}.`;
root.drainQueue();
}
function matchesObserved(entry: var, value: var, answer: var): bool {
if (!answer)
return false;
const expected = root.hyprValue(entry, value);
switch (entry.hypr.readAs) {
case "bool":
return answer.bool === expected;
case "int":
return answer.int === expected;
case "float":
// getoption prints six decimal places; compare within that.
return Math.abs(answer.float - expected) < 1e-5;
case "str":
return answer.str === expected;
case "css":
// Gaps read back as a box, e.g. "10 10 10 10".
return Number(String(answer.css).trim().split(/\s+/)[0]) === expected;
}
return false;
}
function reportWriteFailure(requested: var, text: string): void {
const labels = Object.keys(requested).map(key => PreferenceSchema.spec(key).label);
root.lastError = `Hyprland rejected ${labels.join(" and ")}.`;
root.drainQueue();
}
// The one entry point the settings UI uses to change any preference.
//
// A compositor-backed setting must be applied and verified before it is
// stored, so that preferences never claim a value Hyprland refused.
// Everything else is a direct write. Rows bind a schema key and call this;
// they never need to know which kind they are holding.
function commitPreference(key: string, value: var): bool {
const entry = PreferenceSchema.spec(key);
if (!entry) {
root.lastError = "That setting is not part of Panama.";
return false;
}
if (entry.hypr) {
const batch = {};
batch[key] = value;
return root.applyOptions(batch);
}
return DesktopPreferences.set(key, value);
}
// Restores shipped defaults across every store Panama owns, not just the
// schema. Panama keeps user state in more than one file -- the schema store,
// the focus session, and the Home accessory arrangement -- and a reset that
// silently skipped one would be worse than no reset at all.
//
// Compositor-backed values are re-applied afterwards, since resetting the
// stored value does not by itself tell Hyprland anything.
function restoreDefaults(): void {
DesktopPreferences.resetDesktopDefaults();
// Home accessories keep their own store (panama-home.json), so a reset
// that only cleared the schema store would silently leave a customised
// favourites list behind while claiming to restore Panama's defaults.
//
// Done through HomePreferences' public writable aliases rather than a
// reset function of its own: clearing `favorites` and returning
// `initialized` to false is exactly the state a fresh install has, and
// it lets initialize() seed the list again on next use.
HomePreferences.favorites = [];
HomePreferences.initialized = false;
resettleTimer.restart();
}
Timer {
id: resettleTimer
interval: 60
onTriggered: root.applyPersistedDisplayPolicy()
}
function setAutoHdr(enabled: bool): void {
root.applyOptions({ autoHdr: enabled });
}
function setVrrPolicy(policy: int): void {
root.applyOptions({ vrrPolicy: policy });
}
function setDirectScanoutPolicy(policy: int): void {
root.applyOptions({ directScanoutPolicy: policy });
}
// Replays every compositor-owned preference in one batch at shell start, so
// a value the user changed in Settings survives a reboot even though the
// Lua config only reads the file once, at launch.
function applyPersistedDisplayPolicy(): void {
const values = {};
for (const entry of PreferenceSchema.hyprEntries())
values[entry.key] = DesktopPreferences.get(entry.key);
root.applyOptions(values);
}
function isGnomePanelAllowed(panel: string): bool {
return [
"wifi", "network", "bluetooth", "sound", "power", "printers",
"online-accounts", "users", "mouse", "keyboard", "sharing"
].indexOf(panel) >= 0;
}
function openGnomePanel(panel: string): bool {
if (!root.isGnomePanelAllowed(panel)) {
root.lastError = "That GNOME Settings panel is not available.";
return false;
}
Quickshell.execDetached({
command: ["gnome-control-center", panel],
environment: { "XDG_CURRENT_DESKTOP": "GNOME" }
});
return true;
}
function openApplication(id: string): bool {
const commands = {
"nextcloud": ["nextcloud"],
"rustdesk": ["rustdesk"],
"kdeconnect": ["kdeconnect-app"],
"mission-center": ["flatpak", "run", "io.missioncenter.MissionCenter"],
"bluebubbles": ["flatpak", "run", "app.bluebubbles.BlueBubbles"]
};
const command = commands[id];
if (!command) {
root.lastError = "That application is not managed by Panama Settings.";
return false;
}
Quickshell.execDetached(command);
return true;
}
}