Make Panama settings one shared source of truth
Panama had grown into three configuration surfaces that only agreed because they had been typed to agree: looks.lua hardcoded values, DesktopPreferences independently defaulted the same values, and SystemSettings replayed them at startup. Nothing kept them in sync, and the Lua side read no shared state at all. This lands the first three stages of docs/superpowers/plans/2026-08-17-panama-cohesion.md. Fix silently failing Hyprland writes. On a Lua-configured Hyprland, hyprctl keyword refuses the write, prints the refusal to stdout, and still exits 0, so the HDR, VRR, and direct-scanout toggles persisted their value and reported success while the compositor never changed. Writes now go through hyprctl eval, which has the same hazard on syntax and runtime errors, so success is defined as reading the value back and finding it equal. The existing contract passed throughout the outage because it re-applied the values already in place; the new one flips each value to something it does not hold. Derive preferences from a schema. Every setting used to be restated four times -- a property alias, a JSON adapter property, a change handler, and a line in reset -- where omitting any one failed silently. PreferenceSchema.qml is now the single source, and persistence, validation, reset, and the Hyprland mapping all derive from it. Unknown keys on disk survive a write so a rollback does not discard a newer build's settings, and a corrupt file falls back to shipped defaults. The store moved to ~/.config/panama/settings.json, migrating from the old state directory without deleting it. Share that file with Hyprland. prefs.lua reads it at config time with every shipped literal kept as the fallback, so the config still stands alone. The Lua is the default, the JSON is the truth, and Settings is the editor. The compositor-adjustable surface goes from 3 keys to 23. Also fixes two test-hygiene bugs found by running the suite end to end for the first time: settings-pages-contract could see the window settings-window-contract leaves behind, and the new write contract was persisting its deliberately-wrong values into the user's real store. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
This commit is contained in:
@@ -1,8 +1,23 @@
|
||||
pragma Singleton
|
||||
|
||||
// User choices that Panama Settings may change at runtime. This is deliberately
|
||||
// separate from Settings.qml: the latter remains the stable public surface used
|
||||
// by the shell, while this object owns durable mutation and shipped defaults.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Durable user choices, derived entirely from config/PreferenceSchema.qml.
|
||||
//
|
||||
// This object owns reading, validating, and writing. It knows nothing about
|
||||
// which settings exist -- that is the schema's job -- so adding a setting never
|
||||
// requires touching this file. That is the point: the previous implementation
|
||||
// restated every key four times (a property alias, a JSON adapter property, a
|
||||
// change handler, and a line in reset), and each omission failed silently.
|
||||
//
|
||||
// Reads go through get(), writes through set(). Typed accessors for the values
|
||||
// the shell reads on every frame live in Settings.qml, which remains the stable
|
||||
// public surface.
|
||||
//
|
||||
// The store lives at ~/.config/panama/settings.json rather than inside
|
||||
// Quickshell's per-shell state directory, because the Hyprland Lua config reads
|
||||
// the same file (see config/dot/hypr/prefs.lua) and because a user should be
|
||||
// able to back it up, diff it, or keep it in a dotfiles repo.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
@@ -11,110 +26,125 @@ import QtQuick
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
property alias use24Hour: values.use24Hour
|
||||
property alias showSeconds: values.showSeconds
|
||||
property alias showWeekday: values.showWeekday
|
||||
property alias showCpu: values.showCpu
|
||||
property alias showMemory: values.showMemory
|
||||
property alias showGpu: values.showGpu
|
||||
property alias dockAutohide: values.dockAutohide
|
||||
property alias dockRevealDelayMs: values.dockRevealDelayMs
|
||||
property alias dockHideDelayMs: values.dockHideDelayMs
|
||||
property alias focusDurationMinutes: values.focusDurationMinutes
|
||||
property alias autoHdr: values.autoHdr
|
||||
property alias vrrPolicy: values.vrrPolicy
|
||||
property alias directScanoutPolicy: values.directScanoutPolicy
|
||||
property alias nightLightEnabled: values.nightLightEnabled
|
||||
property alias nightLightAutomatic: values.nightLightAutomatic
|
||||
property alias nightLightTemperature: values.nightLightTemperature
|
||||
property alias lastPage: values.lastPage
|
||||
readonly property string path: (Quickshell.env("XDG_CONFIG_HOME") || `${Quickshell.env("HOME")}/.config`) + "/panama/settings.json"
|
||||
|
||||
// Bumped on every accepted change. get() reads it so bindings built on get()
|
||||
// have something to invalidate; a bare function call would otherwise capture
|
||||
// no dependency and every reader would silently go stale.
|
||||
property int revision: 0
|
||||
|
||||
// Everything currently on disk, including keys this build does not know
|
||||
// about. Unknown keys are carried through untouched so that rolling back to
|
||||
// an older Panama does not discard a newer version's settings.
|
||||
property var values: ({})
|
||||
|
||||
property bool loaded: false
|
||||
|
||||
function get(key: string): var {
|
||||
root.revision;
|
||||
const stored = root.values[key];
|
||||
if (stored === undefined)
|
||||
return PreferenceSchema.defaultFor(key);
|
||||
const coerced = PreferenceSchema.coerce(key, stored);
|
||||
return coerced === undefined ? PreferenceSchema.defaultFor(key) : coerced;
|
||||
}
|
||||
|
||||
// Returns false when the key is unknown or the value cannot be represented,
|
||||
// so a caller can surface the rejection instead of assuming it took.
|
||||
function set(key: string, value: var): bool {
|
||||
if (!PreferenceSchema.has(key))
|
||||
return false;
|
||||
const coerced = PreferenceSchema.coerce(key, value);
|
||||
if (coerced === undefined)
|
||||
return false;
|
||||
if (root.values[key] === coerced)
|
||||
return true;
|
||||
|
||||
// Reassign rather than mutate: QML does not notify on in-place changes
|
||||
// to a var property's contents.
|
||||
const next = Object.assign({}, root.values);
|
||||
next[key] = coerced;
|
||||
root.values = next;
|
||||
root.revision++;
|
||||
persistTimer.restart();
|
||||
return true;
|
||||
}
|
||||
|
||||
// Restores every schema default in one write. Complete by construction --
|
||||
// there is no hand-maintained list to fall out of sync with the schema.
|
||||
function resetDesktopDefaults(): void {
|
||||
const next = Object.assign({}, root.values, PreferenceSchema.defaults());
|
||||
root.values = next;
|
||||
root.revision++;
|
||||
persistTimer.restart();
|
||||
}
|
||||
|
||||
function load(): void {
|
||||
let parsed = {};
|
||||
try {
|
||||
const text = preferencesFile.text();
|
||||
if (text && text.trim().length > 0)
|
||||
parsed = JSON.parse(text);
|
||||
} catch (error) {
|
||||
// A corrupt file must not cost the user a working desktop. Fall
|
||||
// back to shipped defaults and let the next write replace it.
|
||||
parsed = {};
|
||||
}
|
||||
root.values = (parsed && typeof parsed === "object") ? parsed : {};
|
||||
root.revision++;
|
||||
root.loaded = true;
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: preferencesFile
|
||||
|
||||
path: Quickshell.stateDir + "/panama-settings.json"
|
||||
path: root.path
|
||||
blockLoading: true
|
||||
printErrors: false
|
||||
atomicWrites: true
|
||||
|
||||
JsonAdapter {
|
||||
id: values
|
||||
|
||||
property bool use24Hour: false
|
||||
property bool showSeconds: true
|
||||
property bool showWeekday: true
|
||||
property bool showCpu: true
|
||||
property bool showMemory: true
|
||||
property bool showGpu: true
|
||||
property bool dockAutohide: true
|
||||
property int dockRevealDelayMs: 0
|
||||
property int dockHideDelayMs: 250
|
||||
property int focusDurationMinutes: 45
|
||||
property bool autoHdr: true
|
||||
property int vrrPolicy: 3
|
||||
property int directScanoutPolicy: 2
|
||||
property bool nightLightEnabled: false
|
||||
property bool nightLightAutomatic: false
|
||||
property int nightLightTemperature: 3500
|
||||
property string lastPage: "home"
|
||||
}
|
||||
onLoaded: root.load()
|
||||
// No file yet is the normal first-run case, not an error.
|
||||
onLoadFailed: root.load()
|
||||
}
|
||||
|
||||
// Listen after the adapter has been constructed instead of writing from
|
||||
// FileView.onAdapterUpdated. The latter also fires for default-property
|
||||
// initialization, which can overwrite a valid file before it is loaded.
|
||||
Connections {
|
||||
target: values
|
||||
function onUse24HourChanged(): void { persistTimer.restart(); }
|
||||
function onShowSecondsChanged(): void { persistTimer.restart(); }
|
||||
function onShowWeekdayChanged(): void { persistTimer.restart(); }
|
||||
function onShowCpuChanged(): void { persistTimer.restart(); }
|
||||
function onShowMemoryChanged(): void { persistTimer.restart(); }
|
||||
function onShowGpuChanged(): void { persistTimer.restart(); }
|
||||
function onDockAutohideChanged(): void { persistTimer.restart(); }
|
||||
function onDockRevealDelayMsChanged(): void { persistTimer.restart(); }
|
||||
function onDockHideDelayMsChanged(): void { persistTimer.restart(); }
|
||||
function onFocusDurationMinutesChanged(): void { persistTimer.restart(); }
|
||||
function onAutoHdrChanged(): void { persistTimer.restart(); }
|
||||
function onVrrPolicyChanged(): void { persistTimer.restart(); }
|
||||
function onDirectScanoutPolicyChanged(): void { persistTimer.restart(); }
|
||||
function onNightLightEnabledChanged(): void { persistTimer.restart(); }
|
||||
function onNightLightAutomaticChanged(): void { persistTimer.restart(); }
|
||||
function onNightLightTemperatureChanged(): void { persistTimer.restart(); }
|
||||
function onLastPageChanged(): void { persistTimer.restart(); }
|
||||
Component.onCompleted: {
|
||||
migration.adopt();
|
||||
root.load();
|
||||
}
|
||||
|
||||
// Singleton construction can happen after FileView's preload phase when a
|
||||
// test or a lazy page first references it. An explicit reload makes the
|
||||
// durable state authoritative in that case as well as in the main shell.
|
||||
Component.onCompleted: preferencesFile.reload()
|
||||
|
||||
// Coalesce a group of UI changes into one atomic write. Writing from the
|
||||
// adapter's change signal itself can race a second property assignment and
|
||||
// reload the older value before the batch has finished.
|
||||
// Coalesce a burst of changes into one atomic write. Writing on every
|
||||
// assignment races a second assignment and can reload the older value.
|
||||
Timer {
|
||||
id: persistTimer
|
||||
interval: 0
|
||||
onTriggered: preferencesFile.writeAdapter()
|
||||
onTriggered: preferencesFile.setText(JSON.stringify(root.values, null, 2) + "\n")
|
||||
}
|
||||
|
||||
function resetDesktopDefaults(): void {
|
||||
values.use24Hour = false;
|
||||
values.showSeconds = true;
|
||||
values.showWeekday = true;
|
||||
values.showCpu = true;
|
||||
values.showMemory = true;
|
||||
values.showGpu = true;
|
||||
values.dockAutohide = true;
|
||||
values.dockRevealDelayMs = 0;
|
||||
values.dockHideDelayMs = 250;
|
||||
values.focusDurationMinutes = 45;
|
||||
values.autoHdr = true;
|
||||
values.vrrPolicy = 3;
|
||||
values.directScanoutPolicy = 2;
|
||||
values.nightLightEnabled = false;
|
||||
values.nightLightAutomatic = false;
|
||||
values.nightLightTemperature = 3500;
|
||||
values.lastPage = "home";
|
||||
// One-time move from the pre-Stage-1 location inside Quickshell's state
|
||||
// directory. Reads the old file only when the new one does not exist yet, so
|
||||
// it can never overwrite newer settings, and never deletes the original.
|
||||
QtObject {
|
||||
id: migration
|
||||
|
||||
function adopt(): void {
|
||||
if (preferencesFile.text())
|
||||
return;
|
||||
try {
|
||||
const legacy = legacyFile.text();
|
||||
if (legacy && legacy.trim().length > 0)
|
||||
preferencesFile.setText(legacy);
|
||||
} catch (error) {
|
||||
// Nothing to migrate.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
FileView {
|
||||
id: legacyFile
|
||||
|
||||
path: Quickshell.stateDir + "/panama-settings.json"
|
||||
blockLoading: true
|
||||
printErrors: false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,398 @@
|
||||
pragma Singleton
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// The schema is the single source of truth for every user-changeable setting.
|
||||
//
|
||||
// One entry describes a setting completely: its name, type, default, valid
|
||||
// range, which group it belongs to, and how to label it. Persistence,
|
||||
// validation, reset, and (from Stage 3) the settings UI itself are all derived
|
||||
// from these entries rather than restated.
|
||||
//
|
||||
// Adding a setting means adding one entry here. It does not mean editing a
|
||||
// property alias, a JSON adapter, a change handler, and a reset function --
|
||||
// which is what it used to mean, and why the settings app stalled at sixteen
|
||||
// knobs while forty comparable values stayed hardcoded one file away.
|
||||
//
|
||||
// Entry fields
|
||||
// key unique identifier; also the JSON key on disk
|
||||
// type "bool" | "int" | "real" | "string" | "enum"
|
||||
// def shipped default, used when the file is absent or a value is invalid
|
||||
// min/max inclusive bounds for int and real; values outside are clamped
|
||||
// step UI increment for int and real
|
||||
// options for "enum": [{ value, label }], the only accepted values
|
||||
// group grouping id, used to build settings pages
|
||||
// label short UI name
|
||||
// detail one line explaining what changing it does
|
||||
// internal true for state the shell keeps but the user never edits directly
|
||||
// pattern for "string": a regular expression the value must match in full
|
||||
// hypr present when the setting maps onto an Hyprland option:
|
||||
// path the hl.config table path, e.g. ["decoration","blur","size"]
|
||||
// option the getoption path used to read the value back
|
||||
// readAs which field getoption returns it in -- "int", "bool",
|
||||
// "float", "str", or "css" (gaps, returned as a box)
|
||||
//
|
||||
// Anything with a `hypr` block is applied live by services/SystemSettings.qml
|
||||
// and read at startup by config/dot/hypr/prefs.lua, using the same key. The Lua
|
||||
// keeps the shipped value as its fallback, so the config still works with no
|
||||
// settings file at all.
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
import Quickshell
|
||||
import QtQuick
|
||||
|
||||
Singleton {
|
||||
id: root
|
||||
|
||||
readonly property var entries: [
|
||||
// ── Clock ───────────────────────────────────────────────────────────
|
||||
{
|
||||
key: "use24Hour", type: "bool", def: false, group: "clock",
|
||||
label: "24-hour time",
|
||||
detail: "Use 18:30 instead of 6:30 PM"
|
||||
},
|
||||
{
|
||||
key: "showSeconds", type: "bool", def: true, group: "clock",
|
||||
label: "Show seconds",
|
||||
detail: "Keep a precise clock in the center of the bar"
|
||||
},
|
||||
{
|
||||
key: "showWeekday", type: "bool", def: true, group: "clock",
|
||||
label: "Show weekday",
|
||||
detail: "Include the abbreviated weekday before the date"
|
||||
},
|
||||
|
||||
// ── Vitals ──────────────────────────────────────────────────────────
|
||||
{
|
||||
key: "showCpu", type: "bool", def: true, group: "vitals",
|
||||
label: "Processor",
|
||||
detail: "Show processor usage beside the workspace indicator"
|
||||
},
|
||||
{
|
||||
key: "showMemory", type: "bool", def: true, group: "vitals",
|
||||
label: "Memory",
|
||||
detail: "Show memory usage beside the workspace indicator"
|
||||
},
|
||||
{
|
||||
key: "showGpu", type: "bool", def: true, group: "vitals",
|
||||
label: "Graphics",
|
||||
detail: "Show graphics usage beside the workspace indicator"
|
||||
},
|
||||
|
||||
// ── Dock ────────────────────────────────────────────────────────────
|
||||
{
|
||||
key: "dockAutohide", type: "bool", def: true, group: "dock",
|
||||
label: "Automatically hide the Dock",
|
||||
detail: "Reveal it at the bottom edge when a workspace is occupied"
|
||||
},
|
||||
{
|
||||
key: "dockRevealDelayMs", type: "int", def: 0, min: 0, max: 1000, step: 25,
|
||||
group: "dock",
|
||||
label: "Reveal delay",
|
||||
detail: "Zero reveals the Dock the instant the pointer reaches the edge"
|
||||
},
|
||||
{
|
||||
key: "dockHideDelayMs", type: "int", def: 250, min: 0, max: 2000, step: 25,
|
||||
group: "dock",
|
||||
label: "Hide delay",
|
||||
detail: "Prevents flicker when crossing between icons"
|
||||
},
|
||||
|
||||
// ── Focus ───────────────────────────────────────────────────────────
|
||||
{
|
||||
key: "focusDurationMinutes", type: "int", def: 45, min: 5, max: 180, step: 5,
|
||||
group: "focus",
|
||||
label: "Focus session length",
|
||||
detail: "How long a focus session runs before it ends itself"
|
||||
},
|
||||
|
||||
// ── Display policy ──────────────────────────────────────────────────
|
||||
// These three are written to the compositor and verified by read-back.
|
||||
// See services/SystemSettings.qml for why the exit code cannot be
|
||||
// trusted for either hyprctl keyword or hyprctl eval.
|
||||
{
|
||||
key: "autoHdr", type: "bool", def: true, group: "display",
|
||||
label: "Game-aware HDR",
|
||||
detail: "Hand HDR to fullscreen games while the desktop stays SDR",
|
||||
hypr: { path: ["render", "cm_auto_hdr"], option: "render:cm_auto_hdr", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "vrrPolicy", type: "enum", def: 3, group: "display",
|
||||
label: "Variable refresh rate",
|
||||
detail: "Content-aware matches the display to what is on screen",
|
||||
options: [
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 3, label: "Content-aware" }
|
||||
],
|
||||
hypr: { path: ["misc", "vrr"], option: "misc:vrr", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "directScanoutPolicy", type: "enum", def: 2, group: "display",
|
||||
label: "Direct scanout",
|
||||
detail: "Lets fullscreen content bypass compositing",
|
||||
options: [
|
||||
{ value: 0, label: "Off" },
|
||||
{ value: 2, label: "Automatic" }
|
||||
],
|
||||
hypr: { path: ["render", "direct_scanout"], option: "render:direct_scanout", readAs: "int" }
|
||||
},
|
||||
|
||||
// ── Window appearance ───────────────────────────────────────────────
|
||||
// These adjust the parameters of the Prism identity -- how much space,
|
||||
// how soft, how much motion -- rather than replacing it. Shipped values
|
||||
// are duplicated as the fallbacks in config/dot/hypr/looks.lua so that
|
||||
// the Hyprland config still stands on its own.
|
||||
{
|
||||
key: "gapsIn", type: "int", def: 5, min: 0, max: 40, step: 1,
|
||||
group: "windows",
|
||||
label: "Inner gaps",
|
||||
detail: "Space between neighbouring tiled windows",
|
||||
hypr: { path: ["general", "gaps_in"], option: "general:gaps_in", readAs: "css" }
|
||||
},
|
||||
{
|
||||
key: "gapsOut", type: "int", def: 10, min: 0, max: 80, step: 1,
|
||||
group: "windows",
|
||||
label: "Outer gaps",
|
||||
detail: "Space between the tiled area and the screen edge",
|
||||
hypr: { path: ["general", "gaps_out"], option: "general:gaps_out", readAs: "css" }
|
||||
},
|
||||
{
|
||||
key: "borderSize", type: "int", def: 2, min: 0, max: 10, step: 1,
|
||||
group: "windows",
|
||||
label: "Border width",
|
||||
detail: "Thickness of the gradient border on the focused window",
|
||||
hypr: { path: ["general", "border_size"], option: "general:border_size", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "windowRounding", type: "int", def: 18, min: 0, max: 40, step: 1,
|
||||
group: "windows",
|
||||
label: "Corner radius",
|
||||
detail: "Matches the shell's popover radius so windows and panels agree",
|
||||
hypr: { path: ["decoration", "rounding"], option: "decoration:rounding", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "inactiveOpacity", type: "real", def: 1.0, min: 0.5, max: 1.0, step: 0.05,
|
||||
group: "windows",
|
||||
label: "Unfocused window opacity",
|
||||
detail: "Fade windows that do not have focus",
|
||||
hypr: { path: ["decoration", "inactive_opacity"], option: "decoration:inactive_opacity", readAs: "float" }
|
||||
},
|
||||
|
||||
// ── Effects ─────────────────────────────────────────────────────────
|
||||
{
|
||||
key: "blurEnabled", type: "bool", def: true, group: "effects",
|
||||
label: "Blur",
|
||||
detail: "Blur the desktop behind translucent surfaces",
|
||||
hypr: { path: ["decoration", "blur", "enabled"], option: "decoration:blur:enabled", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "blurSize", type: "int", def: 8, min: 1, max: 20, step: 1,
|
||||
group: "effects",
|
||||
label: "Blur radius",
|
||||
detail: "Larger is softer and costs more frame time",
|
||||
hypr: { path: ["decoration", "blur", "size"], option: "decoration:blur:size", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "blurPasses", type: "int", def: 3, min: 1, max: 5, step: 1,
|
||||
group: "effects",
|
||||
label: "Blur passes",
|
||||
detail: "More passes look smoother and cost more frame time",
|
||||
hypr: { path: ["decoration", "blur", "passes"], option: "decoration:blur:passes", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "shadowEnabled", type: "bool", def: true, group: "effects",
|
||||
label: "Window shadows",
|
||||
detail: "Lift windows off the wallpaper with a soft shadow",
|
||||
hypr: { path: ["decoration", "shadow", "enabled"], option: "decoration:shadow:enabled", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "shadowRange", type: "int", def: 20, min: 0, max: 60, step: 1,
|
||||
group: "effects",
|
||||
label: "Shadow size",
|
||||
detail: "How far the shadow spreads from the window edge",
|
||||
hypr: { path: ["decoration", "shadow", "range"], option: "decoration:shadow:range", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "glowEnabled", type: "bool", def: true, group: "effects",
|
||||
label: "Focus glow",
|
||||
detail: "A faint halo behind the focused window",
|
||||
hypr: { path: ["decoration", "glow", "enabled"], option: "decoration:glow:enabled", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "glowRange", type: "int", def: 8, min: 0, max: 30, step: 1,
|
||||
group: "effects",
|
||||
label: "Glow size",
|
||||
detail: "Kept small deliberately: the gradient border is the signature",
|
||||
hypr: { path: ["decoration", "glow", "range"], option: "decoration:glow:range", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "animationsEnabled", type: "bool", def: true, group: "effects",
|
||||
label: "Animations",
|
||||
detail: "Window, workspace, and panel motion",
|
||||
hypr: { path: ["animations", "enabled"], option: "animations:enabled", readAs: "bool" }
|
||||
},
|
||||
|
||||
// ── Input ───────────────────────────────────────────────────────────
|
||||
{
|
||||
key: "keyboardLayout", type: "string", def: "us", group: "input",
|
||||
// Reaches an hl.config string, so it is constrained to the shape of
|
||||
// an XKB layout list and nothing else.
|
||||
pattern: "^[a-z]{2,8}(,[a-z]{2,8})*$",
|
||||
label: "Keyboard layout",
|
||||
detail: "XKB layout name, or a comma-separated list to switch between",
|
||||
hypr: { path: ["input", "kb_layout"], option: "input:kb_layout", readAs: "str" }
|
||||
},
|
||||
{
|
||||
key: "numlockByDefault", type: "bool", def: true, group: "input",
|
||||
label: "Num Lock on login",
|
||||
detail: "Turn Num Lock on when the session starts",
|
||||
hypr: { path: ["input", "numlock_by_default"], option: "input:numlock_by_default", readAs: "bool" }
|
||||
},
|
||||
{
|
||||
key: "keyRepeatDelay", type: "int", def: 500, min: 150, max: 1000, step: 25,
|
||||
group: "input",
|
||||
label: "Repeat delay",
|
||||
detail: "How long a key is held before it starts repeating",
|
||||
hypr: { path: ["input", "repeat_delay"], option: "input:repeat_delay", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "keyRepeatRate", type: "int", def: 33, min: 5, max: 100, step: 1,
|
||||
group: "input",
|
||||
label: "Repeat rate",
|
||||
detail: "How many characters a second a held key produces",
|
||||
hypr: { path: ["input", "repeat_rate"], option: "input:repeat_rate", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "followMouse", type: "enum", def: 1, group: "input",
|
||||
label: "Focus follows pointer",
|
||||
detail: "Click to focus matches GNOME; sloppy focus follows the pointer",
|
||||
options: [
|
||||
{ value: 0, label: "Never" },
|
||||
{ value: 1, label: "Click to focus" },
|
||||
{ value: 2, label: "Sloppy focus" }
|
||||
],
|
||||
hypr: { path: ["input", "follow_mouse"], option: "input:follow_mouse", readAs: "int" }
|
||||
},
|
||||
{
|
||||
key: "pointerSensitivity", type: "real", def: 0.0, min: -1.0, max: 1.0, step: 0.05,
|
||||
group: "input",
|
||||
label: "Pointer speed",
|
||||
detail: "Zero is flat, unaccelerated response",
|
||||
hypr: { path: ["input", "sensitivity"], option: "input:sensitivity", readAs: "float" }
|
||||
},
|
||||
{
|
||||
key: "cursorInactiveTimeout", type: "int", def: 4, min: 0, max: 60, step: 1,
|
||||
group: "input",
|
||||
label: "Hide pointer after",
|
||||
detail: "Seconds of stillness before the pointer fades out; 0 never hides it",
|
||||
hypr: { path: ["cursor", "inactive_timeout"], option: "cursor:inactive_timeout", readAs: "int" }
|
||||
},
|
||||
|
||||
// ── Night light ─────────────────────────────────────────────────────
|
||||
{
|
||||
key: "nightLightEnabled", type: "bool", def: false, group: "nightLight",
|
||||
label: "Night Light",
|
||||
detail: "Shift the display warmer to reduce blue light"
|
||||
},
|
||||
{
|
||||
key: "nightLightAutomatic", type: "bool", def: false, group: "nightLight",
|
||||
label: "Schedule automatically",
|
||||
detail: "Turn Night Light on and off at the scheduled hours"
|
||||
},
|
||||
{
|
||||
key: "nightLightTemperature", type: "int", def: 3500, min: 2000, max: 6500, step: 100,
|
||||
group: "nightLight",
|
||||
label: "Color temperature",
|
||||
detail: "Lower is warmer"
|
||||
},
|
||||
|
||||
// ── Internal ────────────────────────────────────────────────────────
|
||||
{
|
||||
key: "lastPage", type: "string", def: "home", group: "internal",
|
||||
internal: true,
|
||||
label: "Last settings page",
|
||||
detail: "Restores the page Settings was left on"
|
||||
}
|
||||
]
|
||||
|
||||
// key -> entry, built once. Every lookup below goes through this rather than
|
||||
// scanning `entries`, since get/set are called from bindings.
|
||||
readonly property var byKey: {
|
||||
const index = {};
|
||||
for (const entry of root.entries)
|
||||
index[entry.key] = entry;
|
||||
return index;
|
||||
}
|
||||
|
||||
readonly property var userKeys: root.entries.filter(entry => !entry.internal).map(entry => entry.key)
|
||||
|
||||
function spec(key: string): var {
|
||||
return root.byKey[key] ?? null;
|
||||
}
|
||||
|
||||
function has(key: string): bool {
|
||||
return root.byKey[key] !== undefined;
|
||||
}
|
||||
|
||||
function defaultFor(key: string): var {
|
||||
const entry = root.byKey[key];
|
||||
return entry ? entry.def : undefined;
|
||||
}
|
||||
|
||||
function defaults(): var {
|
||||
const out = {};
|
||||
for (const entry of root.entries)
|
||||
out[entry.key] = entry.def;
|
||||
return out;
|
||||
}
|
||||
|
||||
function inGroup(group: string): var {
|
||||
return root.entries.filter(entry => entry.group === group && !entry.internal);
|
||||
}
|
||||
|
||||
// Entries the compositor owns, used to build one hl.config{} payload.
|
||||
function hyprEntries(): var {
|
||||
return root.entries.filter(entry => entry.hypr !== undefined);
|
||||
}
|
||||
|
||||
// Returns the value coerced into the entry's type and range, or `undefined`
|
||||
// if it cannot be represented at all. Out-of-range numbers are clamped
|
||||
// rather than rejected: a stale file with a since-narrowed bound should
|
||||
// still yield a usable desktop.
|
||||
function coerce(key: string, value: var): var {
|
||||
const entry = root.byKey[key];
|
||||
if (!entry || value === undefined || value === null)
|
||||
return undefined;
|
||||
|
||||
switch (entry.type) {
|
||||
case "bool":
|
||||
if (typeof value === "boolean") return value;
|
||||
if (value === "true") return true;
|
||||
if (value === "false") return false;
|
||||
return undefined;
|
||||
|
||||
case "int":
|
||||
case "real": {
|
||||
const numeric = Number(value);
|
||||
if (!isFinite(numeric)) return undefined;
|
||||
const rounded = entry.type === "int" ? Math.round(numeric) : numeric;
|
||||
const lower = entry.min !== undefined ? Math.max(rounded, entry.min) : rounded;
|
||||
return entry.max !== undefined ? Math.min(lower, entry.max) : lower;
|
||||
}
|
||||
|
||||
case "enum":
|
||||
return entry.options.some(option => option.value === value) ? value : undefined;
|
||||
|
||||
case "string": {
|
||||
const text = typeof value === "string" ? value : String(value);
|
||||
// A constrained string is rejected rather than sanitised. Several
|
||||
// of these are serialised into an hl.config payload, and quietly
|
||||
// stripping characters would turn a typo into a different setting
|
||||
// instead of an error the user can see.
|
||||
if (entry.pattern && !new RegExp(entry.pattern).test(text))
|
||||
return undefined;
|
||||
return text;
|
||||
}
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
@@ -15,9 +15,9 @@ Singleton {
|
||||
|
||||
// ── Clock ───────────────────────────────────────────────────────────────
|
||||
// Carried over from GNOME: 12-hour, weekday + date shown, seconds on.
|
||||
readonly property bool use24Hour: DesktopPreferences.use24Hour
|
||||
readonly property bool showSeconds: DesktopPreferences.showSeconds
|
||||
readonly property bool showWeekday: DesktopPreferences.showWeekday
|
||||
readonly property bool use24Hour: DesktopPreferences.get("use24Hour")
|
||||
readonly property bool showSeconds: DesktopPreferences.get("showSeconds")
|
||||
readonly property bool showWeekday: DesktopPreferences.get("showWeekday")
|
||||
|
||||
// ── Weather ─────────────────────────────────────────────────────────────
|
||||
// Coordinates taken from the GNOME night-light setting, which had already
|
||||
@@ -35,9 +35,9 @@ Singleton {
|
||||
// The GNOME Vitals extension showed processor usage, memory usage and GPU
|
||||
// usage, in that order. Same here.
|
||||
readonly property int vitalsIntervalMs: 2000
|
||||
readonly property bool showCpu: DesktopPreferences.showCpu
|
||||
readonly property bool showMemory: DesktopPreferences.showMemory
|
||||
readonly property bool showGpu: DesktopPreferences.showGpu
|
||||
readonly property bool showCpu: DesktopPreferences.get("showCpu")
|
||||
readonly property bool showMemory: DesktopPreferences.get("showMemory")
|
||||
readonly property bool showGpu: DesktopPreferences.get("showGpu")
|
||||
|
||||
// amdgpu exposes utilisation here. Verified present on this machine; the
|
||||
// widget hides itself if the path is missing rather than showing zeros.
|
||||
@@ -45,10 +45,10 @@ Singleton {
|
||||
|
||||
// ── Night light ─────────────────────────────────────────────────────────
|
||||
// Matches the (disabled) GNOME schedule: 3500K from 17:00 to 10:00.
|
||||
readonly property int nightLightTemperature: DesktopPreferences.nightLightTemperature
|
||||
readonly property int nightLightTemperature: DesktopPreferences.get("nightLightTemperature")
|
||||
readonly property real nightLightFrom: 17.0
|
||||
readonly property real nightLightTo: 10.0
|
||||
readonly property bool nightLightEnabledByDefault: DesktopPreferences.nightLightEnabled
|
||||
readonly property bool nightLightEnabledByDefault: DesktopPreferences.get("nightLightEnabled")
|
||||
|
||||
// ── Notifications ───────────────────────────────────────────────────────
|
||||
readonly property int notificationTimeoutMs: 5000
|
||||
@@ -59,7 +59,7 @@ Singleton {
|
||||
// ── Focus ──────────────────────────────────────────────────────────────
|
||||
// One deliberate default rather than a preset picker: quick settings and
|
||||
// the keyboard shortcut should start a useful session in one action.
|
||||
readonly property int focusDurationMinutes: DesktopPreferences.focusDurationMinutes
|
||||
readonly property int focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes")
|
||||
|
||||
// ── Dock ────────────────────────────────────────────────────────────────
|
||||
// Pinned apps, in order, taken from the GNOME dash favourites.
|
||||
@@ -84,16 +84,16 @@ Singleton {
|
||||
|
||||
// Dash-to-Dock was set to intellihide against all windows: the dock hides
|
||||
// when any window would overlap it, and comes back on hover.
|
||||
readonly property bool dockAutohide: DesktopPreferences.dockAutohide
|
||||
readonly property bool dockAutohide: DesktopPreferences.get("dockAutohide")
|
||||
|
||||
// 0: reveal the instant the pointer reaches the bottom edge. A reveal delay
|
||||
// is indistinguishable from lag, because the user has already committed to
|
||||
// the gesture by the time the strip is hit.
|
||||
readonly property int dockRevealDelayMs: DesktopPreferences.dockRevealDelayMs
|
||||
readonly property int dockRevealDelayMs: DesktopPreferences.get("dockRevealDelayMs")
|
||||
|
||||
// Hiding keeps a delay, so brushing past the bottom edge or crossing the
|
||||
// gap between two icons doesn't make the dock flicker.
|
||||
readonly property int dockHideDelayMs: DesktopPreferences.dockHideDelayMs
|
||||
readonly property int dockHideDelayMs: DesktopPreferences.get("dockHideDelayMs")
|
||||
|
||||
// ── Capture ─────────────────────────────────────────────────────────────
|
||||
readonly property string screenshotDir: "Pictures/Screenshots"
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
module qs.config
|
||||
singleton DesktopPreferences 1.0 DesktopPreferences.qml
|
||||
singleton PreferenceSchema 1.0 PreferenceSchema.qml
|
||||
singleton Settings 1.0 Settings.qml
|
||||
singleton Theme 1.0 Theme.qml
|
||||
|
||||
@@ -32,29 +32,29 @@ Item {
|
||||
label: "24-hour time"
|
||||
detail: "Use 18:30 instead of 6:30 PM"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.use24Hour; onToggled: value => DesktopPreferences.use24Hour = value }
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("use24Hour"); onToggled: value => DesktopPreferences.set("use24Hour", value) }
|
||||
}
|
||||
SettingRow {
|
||||
label: "Show seconds"
|
||||
detail: "Keep a precise clock in the center of the bar"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showSeconds; onToggled: value => DesktopPreferences.showSeconds = value }
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showSeconds"); onToggled: value => DesktopPreferences.set("showSeconds", value) }
|
||||
}
|
||||
SettingRow {
|
||||
label: "Show weekday"
|
||||
detail: "Include the abbreviated weekday before the date"
|
||||
divider: false
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showWeekday; onToggled: value => DesktopPreferences.showWeekday = value }
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showWeekday"); onToggled: value => DesktopPreferences.set("showWeekday", value) }
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
title: "System vitals"
|
||||
subtitle: "Choose what appears beside the workspace indicator."
|
||||
SettingRow { label: "Processor"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showCpu; onToggled: value => DesktopPreferences.showCpu = value } }
|
||||
SettingRow { label: "Memory"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showMemory; onToggled: value => DesktopPreferences.showMemory = value } }
|
||||
SettingRow { label: "Graphics"; divider: false; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.showGpu; onToggled: value => DesktopPreferences.showGpu = value } }
|
||||
SettingRow { label: "Processor"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showCpu"); onToggled: value => DesktopPreferences.set("showCpu", value) } }
|
||||
SettingRow { label: "Memory"; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showMemory"); onToggled: value => DesktopPreferences.set("showMemory", value) } }
|
||||
SettingRow { label: "Graphics"; divider: false; controlWidth: 48; SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("showGpu"); onToggled: value => DesktopPreferences.set("showGpu", value) } }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,10 +25,10 @@ Item {
|
||||
label: "Automatically hide the Dock"
|
||||
detail: "Reveal it at the bottom edge when a workspace is occupied"
|
||||
controlWidth: 48
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.dockAutohide; onToggled: value => DesktopPreferences.dockAutohide = value }
|
||||
SettingsToggle { anchors.right: parent.right; anchors.verticalCenter: parent.verticalCenter; checked: DesktopPreferences.get("dockAutohide"); onToggled: value => DesktopPreferences.set("dockAutohide", value) }
|
||||
}
|
||||
SettingRow { label: "Reveal response"; detail: "The Dock appears as soon as the pointer reaches the edge"; value: DesktopPreferences.dockRevealDelayMs === 0 ? "Instant" : `${DesktopPreferences.dockRevealDelayMs} ms` }
|
||||
SettingRow { label: "Hide delay"; detail: "Prevents flicker when crossing icons"; value: `${DesktopPreferences.dockHideDelayMs} ms`; divider: false }
|
||||
SettingRow { label: "Reveal response"; detail: "The Dock appears as soon as the pointer reaches the edge"; value: DesktopPreferences.get("dockRevealDelayMs") === 0 ? "Instant" : `${DesktopPreferences.get("dockRevealDelayMs")} ms` }
|
||||
SettingRow { label: "Hide delay"; detail: "Prevents flicker when crossing icons"; value: `${DesktopPreferences.get("dockHideDelayMs")} ms`; divider: false }
|
||||
}
|
||||
|
||||
SettingsCard {
|
||||
|
||||
@@ -54,8 +54,8 @@ Item {
|
||||
SettingsButton {
|
||||
required property int modelData
|
||||
text: `${modelData}m`
|
||||
tone: DesktopPreferences.focusDurationMinutes === modelData ? "accent" : "normal"
|
||||
onClicked: DesktopPreferences.focusDurationMinutes = modelData
|
||||
tone: DesktopPreferences.get("focusDurationMinutes") === modelData ? "accent" : "normal"
|
||||
onClicked: DesktopPreferences.set("focusDurationMinutes", modelData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
import Quickshell
|
||||
import Quickshell.Io
|
||||
import QtQuick
|
||||
|
||||
import qs.config
|
||||
|
||||
ShellRoot {
|
||||
IpcHandler {
|
||||
target: "preference-schema-test"
|
||||
|
||||
// Values arrive as a JSON object so the contract can exercise real
|
||||
// types -- booleans, integers, and strings -- through one entry point.
|
||||
// Returns the per-key result of set(), so a rejection is observable
|
||||
// rather than inferred from the value not changing.
|
||||
function applyJson(payload: string): string {
|
||||
const requested = JSON.parse(payload);
|
||||
const accepted = {};
|
||||
for (const key in requested)
|
||||
accepted[key] = DesktopPreferences.set(key, requested[key]);
|
||||
return JSON.stringify(accepted);
|
||||
}
|
||||
|
||||
// Every schema key and its effective value.
|
||||
function dump(): string {
|
||||
const out = {};
|
||||
for (const entry of PreferenceSchema.entries)
|
||||
out[entry.key] = DesktopPreferences.get(entry.key);
|
||||
return JSON.stringify(out);
|
||||
}
|
||||
|
||||
// The raw in-memory store, including keys this build does not know.
|
||||
function raw(): string {
|
||||
return JSON.stringify(DesktopPreferences.values);
|
||||
}
|
||||
|
||||
function defaults(): string {
|
||||
return JSON.stringify(PreferenceSchema.defaults());
|
||||
}
|
||||
|
||||
function reset(): void {
|
||||
DesktopPreferences.resetDesktopDefaults();
|
||||
}
|
||||
|
||||
function keyCount(): int {
|
||||
return PreferenceSchema.entries.length;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -33,7 +33,7 @@ Singleton {
|
||||
|
||||
// Follow Settings.nightLightFrom .. nightLightTo instead of the manual
|
||||
// switch. Off by default because GNOME's schedule was disabled.
|
||||
property bool automatic: DesktopPreferences.nightLightAutomatic
|
||||
property bool automatic: DesktopPreferences.get("nightLightAutomatic")
|
||||
|
||||
// What is actually applied right now.
|
||||
readonly property bool active: root.automatic ? root.scheduled : root.enabled
|
||||
@@ -81,13 +81,13 @@ Singleton {
|
||||
|
||||
// Re-tune in place; restarting the daemon would flash the display.
|
||||
onTemperatureChanged: {
|
||||
DesktopPreferences.nightLightTemperature = root.temperature;
|
||||
DesktopPreferences.set("nightLightTemperature", root.temperature);
|
||||
if (daemon.running)
|
||||
Quickshell.execDetached(["hyprctl", "hyprsunset", "temperature", String(root.temperature)]);
|
||||
}
|
||||
|
||||
onEnabledChanged: DesktopPreferences.nightLightEnabled = root.enabled
|
||||
onAutomaticChanged: DesktopPreferences.nightLightAutomatic = root.automatic
|
||||
onEnabledChanged: DesktopPreferences.set("nightLightEnabled", root.enabled)
|
||||
onAutomaticChanged: DesktopPreferences.set("nightLightAutomatic", root.automatic)
|
||||
|
||||
onActiveChanged: {
|
||||
if (!root.initialized)
|
||||
|
||||
@@ -94,7 +94,7 @@ Singleton {
|
||||
function openSettings(page: string): void {
|
||||
const allowed = ["home", "appearance", "displays", "connectivity", "desktop", "sound", "notifications", "screen-intelligence", "shortcuts", "services", "about"];
|
||||
root.settingsPage = allowed.indexOf(page) >= 0 ? page : "home";
|
||||
DesktopPreferences.lastPage = root.settingsPage;
|
||||
DesktopPreferences.set("lastPage", root.settingsPage);
|
||||
root.settingsOpen = true;
|
||||
}
|
||||
|
||||
@@ -103,7 +103,7 @@ Singleton {
|
||||
root.closeSettings();
|
||||
return;
|
||||
}
|
||||
root.openSettings(DesktopPreferences.lastPage || "home");
|
||||
root.openSettings(DesktopPreferences.get("lastPage") || "home");
|
||||
}
|
||||
|
||||
function closeSettings(): void {
|
||||
|
||||
@@ -33,11 +33,32 @@ Singleton {
|
||||
property string lastError: ""
|
||||
|
||||
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|
||||
|| autoHdrWrite.running || vrrWrite.running || directScanoutWrite.running
|
||||
|| configWrite.running || configVerify.running
|
||||
|
||||
readonly property bool autoHdr: DesktopPreferences.autoHdr
|
||||
readonly property int vrrPolicy: DesktopPreferences.vrrPolicy
|
||||
readonly property int directScanoutPolicy: DesktopPreferences.directScanoutPolicy
|
||||
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
|
||||
@@ -79,42 +100,33 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
// 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: autoHdrWrite
|
||||
property bool requested: true
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode === 0) {
|
||||
DesktopPreferences.autoHdr = requested;
|
||||
root.lastError = "";
|
||||
} else {
|
||||
root.lastError = "Hyprland rejected the HDR policy.";
|
||||
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();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Reads the written options back out of the compositor. This is the only
|
||||
// thing that decides whether a write succeeded.
|
||||
Process {
|
||||
id: vrrWrite
|
||||
property int requested: 3
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode === 0) {
|
||||
DesktopPreferences.vrrPolicy = requested;
|
||||
root.lastError = "";
|
||||
} else {
|
||||
root.lastError = "Hyprland rejected the VRR policy.";
|
||||
}
|
||||
}
|
||||
}
|
||||
id: configVerify
|
||||
|
||||
Process {
|
||||
id: directScanoutWrite
|
||||
property int requested: 2
|
||||
onExited: (exitCode, exitStatus) => {
|
||||
if (exitCode === 0) {
|
||||
DesktopPreferences.directScanoutPolicy = requested;
|
||||
root.lastError = "";
|
||||
} else {
|
||||
root.lastError = "Hyprland rejected the direct-scanout policy.";
|
||||
}
|
||||
stdout: StdioCollector {
|
||||
onStreamFinished: root.commitVerified(configWrite.pending, this.text)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -172,33 +184,167 @@ Singleton {
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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;
|
||||
if (configWrite.running || configVerify.running) {
|
||||
root.lastError = "Another change is still being applied.";
|
||||
return false;
|
||||
}
|
||||
|
||||
configWrite.pending = requested;
|
||||
configWrite.exec(["hyprctl", "eval", root.buildConfigPayload(requested)]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// 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 ")}.`;
|
||||
}
|
||||
|
||||
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 ")}.`;
|
||||
}
|
||||
|
||||
function setAutoHdr(enabled: bool): void {
|
||||
autoHdrWrite.requested = enabled;
|
||||
autoHdrWrite.exec(["hyprctl", "keyword", "render:cm_auto_hdr", enabled ? "1" : "0"]);
|
||||
root.applyOptions({ autoHdr: enabled });
|
||||
}
|
||||
|
||||
function setVrrPolicy(policy: int): void {
|
||||
if (policy !== 0 && policy !== 3) {
|
||||
root.lastError = "Unsupported VRR policy.";
|
||||
return;
|
||||
}
|
||||
vrrWrite.requested = policy;
|
||||
vrrWrite.exec(["hyprctl", "keyword", "misc:vrr", String(policy)]);
|
||||
root.applyOptions({ vrrPolicy: policy });
|
||||
}
|
||||
|
||||
function setDirectScanoutPolicy(policy: int): void {
|
||||
if (policy !== 0 && policy !== 2) {
|
||||
root.lastError = "Unsupported direct-scanout policy.";
|
||||
return;
|
||||
}
|
||||
directScanoutWrite.requested = policy;
|
||||
directScanoutWrite.exec(["hyprctl", "keyword", "render:direct_scanout", String(policy)]);
|
||||
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 {
|
||||
root.setAutoHdr(DesktopPreferences.autoHdr);
|
||||
root.setVrrPolicy(DesktopPreferences.vrrPolicy);
|
||||
root.setDirectScanoutPolicy(DesktopPreferences.directScanoutPolicy);
|
||||
const values = {};
|
||||
for (const entry of PreferenceSchema.hyprEntries())
|
||||
values[entry.key] = DesktopPreferences.get(entry.key);
|
||||
root.applyOptions(values);
|
||||
}
|
||||
|
||||
function isGnomePanelAllowed(panel: string): bool {
|
||||
|
||||
@@ -9,32 +9,32 @@ ShellRoot {
|
||||
target: "settings-pref-test"
|
||||
|
||||
function applyFixture(): void {
|
||||
DesktopPreferences.use24Hour = true;
|
||||
DesktopPreferences.showSeconds = false;
|
||||
DesktopPreferences.dockAutohide = false;
|
||||
DesktopPreferences.focusDurationMinutes = 70;
|
||||
DesktopPreferences.autoHdr = false;
|
||||
DesktopPreferences.vrrPolicy = 0;
|
||||
DesktopPreferences.directScanoutPolicy = 0;
|
||||
DesktopPreferences.nightLightEnabled = true;
|
||||
DesktopPreferences.nightLightAutomatic = true;
|
||||
DesktopPreferences.nightLightTemperature = 4100;
|
||||
DesktopPreferences.lastPage = "desktop";
|
||||
DesktopPreferences.set("use24Hour", true);
|
||||
DesktopPreferences.set("showSeconds", false);
|
||||
DesktopPreferences.set("dockAutohide", false);
|
||||
DesktopPreferences.set("focusDurationMinutes", 70);
|
||||
DesktopPreferences.set("autoHdr", false);
|
||||
DesktopPreferences.set("vrrPolicy", 0);
|
||||
DesktopPreferences.set("directScanoutPolicy", 0);
|
||||
DesktopPreferences.set("nightLightEnabled", true);
|
||||
DesktopPreferences.set("nightLightAutomatic", true);
|
||||
DesktopPreferences.set("nightLightTemperature", 4100);
|
||||
DesktopPreferences.set("lastPage", "desktop");
|
||||
}
|
||||
|
||||
function status(): string {
|
||||
return JSON.stringify({
|
||||
use24Hour: DesktopPreferences.use24Hour,
|
||||
showSeconds: DesktopPreferences.showSeconds,
|
||||
dockAutohide: DesktopPreferences.dockAutohide,
|
||||
focusDurationMinutes: DesktopPreferences.focusDurationMinutes,
|
||||
autoHdr: DesktopPreferences.autoHdr,
|
||||
vrrPolicy: DesktopPreferences.vrrPolicy,
|
||||
directScanoutPolicy: DesktopPreferences.directScanoutPolicy,
|
||||
nightLightEnabled: DesktopPreferences.nightLightEnabled,
|
||||
nightLightAutomatic: DesktopPreferences.nightLightAutomatic,
|
||||
nightLightTemperature: DesktopPreferences.nightLightTemperature,
|
||||
lastPage: DesktopPreferences.lastPage,
|
||||
use24Hour: DesktopPreferences.get("use24Hour"),
|
||||
showSeconds: DesktopPreferences.get("showSeconds"),
|
||||
dockAutohide: DesktopPreferences.get("dockAutohide"),
|
||||
focusDurationMinutes: DesktopPreferences.get("focusDurationMinutes"),
|
||||
autoHdr: DesktopPreferences.get("autoHdr"),
|
||||
vrrPolicy: DesktopPreferences.get("vrrPolicy"),
|
||||
directScanoutPolicy: DesktopPreferences.get("directScanoutPolicy"),
|
||||
nightLightEnabled: DesktopPreferences.get("nightLightEnabled"),
|
||||
nightLightAutomatic: DesktopPreferences.get("nightLightAutomatic"),
|
||||
nightLightTemperature: DesktopPreferences.get("nightLightTemperature"),
|
||||
lastPage: DesktopPreferences.get("lastPage"),
|
||||
stateDir: Quickshell.stateDir
|
||||
});
|
||||
}
|
||||
|
||||
@@ -11,10 +11,22 @@ ShellRoot {
|
||||
|
||||
function refresh(): void { SystemSettings.refresh(); }
|
||||
|
||||
// One batch, matching how the shell applies persisted policy. Three
|
||||
// separate setters would be refused as overlapping writes, since each
|
||||
// one is only complete after its value has been read back.
|
||||
function apply(autoHdr: bool, vrr: int, directScanout: int): void {
|
||||
SystemSettings.setAutoHdr(autoHdr);
|
||||
SystemSettings.setVrrPolicy(vrr);
|
||||
SystemSettings.setDirectScanoutPolicy(directScanout);
|
||||
SystemSettings.applyOptions({
|
||||
autoHdr: autoHdr,
|
||||
vrrPolicy: vrr,
|
||||
directScanoutPolicy: directScanout
|
||||
});
|
||||
}
|
||||
|
||||
// Applies an arbitrary batch of schema keys, so the contract can cover
|
||||
// every getoption answer shape -- int, bool, float, str, and the css
|
||||
// box that gaps read back as -- not just the display policies.
|
||||
function applyJson(payload: string): bool {
|
||||
return SystemSettings.applyOptions(JSON.parse(payload));
|
||||
}
|
||||
|
||||
function panelAllowed(panel: string): bool {
|
||||
|
||||
@@ -273,7 +273,7 @@ ShellRoot {
|
||||
|
||||
IpcHandler {
|
||||
target: "settings"
|
||||
function open(): void { ShellState.openSettings(DesktopPreferences.lastPage || "home"); }
|
||||
function open(): void { ShellState.openSettings(DesktopPreferences.get("lastPage") || "home"); }
|
||||
function toggle(): void { ShellState.toggleSettings(); }
|
||||
function close(): void { ShellState.closeSettings(); }
|
||||
function page(name: string): void { ShellState.openSettings(name); }
|
||||
|
||||
Reference in New Issue
Block a user