804 lines
34 KiB
QML
804 lines
34 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: ""
|
|
|
|
// Asked of the binary, never restated. The literal is only what About falls
|
|
// back to on a machine where `qs --version` cannot be run: a hardcoded
|
|
// version is a fact that goes stale the first time Quickshell updates and
|
|
// then reports the wrong number with complete confidence.
|
|
property string quickshellVersion: "0.3.0"
|
|
property bool quickshellVersionRead: false
|
|
|
|
property string lastError: ""
|
|
|
|
// Explicit seams keep reset sequencing testable without changing the live
|
|
// keymap, wallpaper, or display from an isolated contract harness.
|
|
property var displayBusy: function() { return Displays.busy || Displays.awaitingConfirmation; }
|
|
property var readDisplays: function() { return DesktopPreferences.get("displays"); }
|
|
property var protectDisplays: function(value) { return DesktopPreferences.set("displays", value); }
|
|
|
|
// Installed by SettingsBackup at startup. A default no-op rather than a
|
|
// direct reference, because SettingsBackup already references this
|
|
// singleton and a mutual reference between two singletons is an
|
|
// initialisation-order problem waiting to happen. Tests override it the
|
|
// same way they override the seams above.
|
|
//
|
|
// Contract: takeSafetySnapshot(done) returns false when the snapshot could
|
|
// not even be STARTED, and otherwise calls done(success) once the helper
|
|
// has finished. It is asynchronous, so the return value says nothing about
|
|
// whether anything is on disk yet.
|
|
property var takeSafetySnapshot: function(done) { return false; }
|
|
property var setDisplayBlocked: function(blocked) { Displays.externalChangeBlocked = blocked; }
|
|
property var reloadKeybinds: function() { Keybinds.applyReload(); }
|
|
property var keybindsReloading: function() { return Keybinds.reloading; }
|
|
property var applyWallpaper: function(path) { Wallpaper.set(path); }
|
|
property var regenerateLock: function() { LockScreen.regenerate(); }
|
|
property var lockBusy: function() { return LockScreen.busy; }
|
|
|
|
readonly property bool busy: monitorQuery.running || serviceQuery.running || versionQuery.running
|
|
|| quickshellVersionQuery.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();
|
|
}
|
|
}
|
|
}
|
|
|
|
// Started once, from refresh(), and never again once it has answered --
|
|
// the version cannot change under a running shell. `qs --version` prints
|
|
// "Quickshell 0.3.1 (revision ..., distributed by ...)"; only the number
|
|
// is a fact About needs, and a line that does not match leaves the
|
|
// fallback in place rather than putting a parse failure on screen.
|
|
Process {
|
|
id: quickshellVersionQuery
|
|
command: ["qs", "--version"]
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
const match = this.text.match(/Quickshell\s+(\d+(?:\.\d+)*)/);
|
|
if (match) {
|
|
root.quickshellVersion = match[1];
|
|
root.quickshellVersionRead = true;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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 (!quickshellVersionQuery.running && !root.quickshellVersionRead)
|
|
quickshellVersionQuery.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 ────────────────────────────────────────────────────
|
|
// Test harnesses may replace the external compositor boundary while still
|
|
// exercising validation, commit routing, persistence, and reset replay.
|
|
// Production leaves this unset and always uses the verified Hyprland path.
|
|
property var compositorApplyOverride: null
|
|
|
|
// `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 (root.compositorApplyOverride !== null)
|
|
return root.compositorApplyOverride(requested);
|
|
|
|
// 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 {
|
|
// Some options are phrased as a negative by the compositor -- the four
|
|
// Hyprland notices are all `disable_x` -- while the setting reads as
|
|
// "show x", because a switch labeled "Disable splash text" that must
|
|
// be ON to hide something is a small cruelty. `invert` bridges the two,
|
|
// in exactly one place, so nothing downstream has to remember which
|
|
// options are backwards.
|
|
if (entry.hypr.invert === true && typeof value === "boolean")
|
|
value = !value;
|
|
|
|
if (typeof value === "boolean" && entry.hypr.readAs !== "bool")
|
|
return value ? 1 : 0;
|
|
return value;
|
|
}
|
|
|
|
// Serializes 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.serializeValue(root.hyprValue(entry, requested[key]));
|
|
}
|
|
return `hl.config(${root.serializeTable(tree)})`;
|
|
}
|
|
|
|
function serializeValue(value: var): string {
|
|
if (typeof value === "boolean")
|
|
return value ? "true" : "false";
|
|
if (typeof value === "number")
|
|
return String(value);
|
|
|
|
// A gradient is the one setting whose Lua form is not a scalar. The
|
|
// stubs declare it as `string|{colors:string[], angle?:number}`, and
|
|
// the string form only ever carries ONE stop -- writing
|
|
// "rgba(a) rgba(b) 45deg" as a string is accepted and silently keeps
|
|
// the previous value, which is how a two-stop write looks like it
|
|
// worked and did nothing. Multi-stop must be the table form.
|
|
if (value && typeof value === "object" && Array.isArray(value.colors)) {
|
|
const stops = value.colors
|
|
.map(stop => `"${String(stop).replace(/["\\]/g, "")}"`)
|
|
.join(", ");
|
|
const angle = Number(value.angle);
|
|
return `{ colors = { ${stops} }` + (isFinite(angle) ? `, angle = ${angle} }` : ` }`);
|
|
}
|
|
|
|
// A vec2 reaches Lua as a two-element table.
|
|
if (Array.isArray(value) && value.length === 2)
|
|
return `{ ${Number(value[0])}, ${Number(value[1])} }`;
|
|
// Strings only reach here after the schema's pattern check; quoting is
|
|
// belt-and-braces rather than the primary defense.
|
|
return `"${String(value).replace(/["\\]/g, "")}"`;
|
|
}
|
|
|
|
function serializeTable(node: var): string {
|
|
const parts = [];
|
|
for (const name in node) {
|
|
const child = node[name];
|
|
// A leaf arrives pre-serialized as a string; anything else is
|
|
// either a nested section or a structured value (gradient, vec2)
|
|
// that serializeValue knows how to render.
|
|
const rendered = typeof child === "string"
|
|
? child
|
|
: (Array.isArray(child) || (child && child.colors !== undefined)
|
|
? root.serializeValue(child)
|
|
: root.serializeTable(child));
|
|
parts.push(`${name} = ${rendered}`);
|
|
}
|
|
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();
|
|
}
|
|
|
|
// Gradients are written in one notation and read back in another, so they
|
|
// cannot be compared directly the way every other type can.
|
|
//
|
|
// written: { colors = { "rgba(3b426199)" }, angle = 45 }
|
|
// read: "993b4261 45deg"
|
|
//
|
|
// The stops swap to AARRGGBB order, lose their wrapper, and the angle is
|
|
// always appended even when it was never given. Comparing the raw strings
|
|
// reports every gradient write as rejected, which is what would have
|
|
// happened had this been added with readAs: "str".
|
|
function gradientMatches(expected: var, observed: string): bool {
|
|
if (typeof observed !== "string")
|
|
return false;
|
|
return root.normalizeGradient(expected) === root.normalizeGradient(observed);
|
|
}
|
|
|
|
// Both notations reduced to "aarrggbb aarrggbb Ndeg".
|
|
function normalizeGradient(value: var): string {
|
|
const stops = [];
|
|
let angle = 0;
|
|
|
|
const readStop = function (text: string): void {
|
|
const rgba = String(text).match(/rgba?\(\s*([0-9a-fA-F]{6,8})\s*\)/);
|
|
if (rgba) {
|
|
let hex = rgba[1].toLowerCase();
|
|
// rgb() has no alpha; the compositor reports it as fully opaque.
|
|
if (hex.length === 6)
|
|
hex = hex + "ff";
|
|
// RRGGBBAA in, AARRGGBB out.
|
|
stops.push(hex.slice(6, 8) + hex.slice(0, 6));
|
|
return;
|
|
}
|
|
const bare = String(text).match(/^([0-9a-fA-F]{8})$/);
|
|
if (bare) {
|
|
stops.push(bare[1].toLowerCase());
|
|
return;
|
|
}
|
|
const deg = String(text).match(/^(-?[0-9.]+)deg$/);
|
|
if (deg)
|
|
angle = Number(deg[1]);
|
|
};
|
|
|
|
if (value && typeof value === "object" && Array.isArray(value.colors)) {
|
|
value.colors.forEach(readStop);
|
|
if (value.angle !== undefined && isFinite(Number(value.angle)))
|
|
angle = Number(value.angle);
|
|
} else {
|
|
String(value).trim().split(/\s+/).forEach(readStop);
|
|
}
|
|
|
|
return stops.join(" ") + " " + angle + "deg";
|
|
}
|
|
|
|
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;
|
|
case "gradient":
|
|
return root.gradientMatches(expected, answer.gradient);
|
|
case "vec2":
|
|
return Array.isArray(answer.vec2) && Array.isArray(expected)
|
|
&& Number(answer.vec2[0]) === Number(expected[0])
|
|
&& Number(answer.vec2[1]) === Number(expected[1]);
|
|
}
|
|
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);
|
|
}
|
|
|
|
// True from the moment a reset asks for its safety snapshot until that
|
|
// snapshot answers. The UI has no undo, so a second request in that window
|
|
// is refused rather than allowed to race the first one's wipe, and the page
|
|
// keeps the confirmation on screen for as long as it is true.
|
|
property bool resetPending: false
|
|
|
|
// 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.
|
|
//
|
|
// Returns whether the reset was STARTED. The wipe itself happens later, in
|
|
// the snapshot's success path -- see performReset below.
|
|
function restoreDefaults(): bool {
|
|
if (root.displayBusy()) {
|
|
root.lastError = "Finish the current display change before restoring defaults.";
|
|
return false;
|
|
}
|
|
if (root.resetPending) {
|
|
root.lastError = "A reset is already under way.";
|
|
return false;
|
|
}
|
|
|
|
// Snapshot before wiping. Restoring defaults clears every preference
|
|
// and the Home accessory store, and there is no undo for it anywhere in
|
|
// the app -- so the one automatic snapshot Panama takes is the one taken
|
|
// immediately before the only irreversible action it offers.
|
|
//
|
|
// The snapshot is a separate process reading settings.json. Wiping the
|
|
// stores here, as this used to, meant the file was already back to
|
|
// shipped defaults by the time the helper opened it: the "undoable"
|
|
// promise in the confirm recorded the RESET state, not the user's. So
|
|
// the reset now lives entirely inside the completion path, and a
|
|
// snapshot that fails takes the reset down with it -- the alternative is
|
|
// an irreversible action offered as a reversible one.
|
|
root.resetPending = true;
|
|
const started = root.takeSafetySnapshot(function(success) {
|
|
root.resetPending = false;
|
|
if (!success) {
|
|
root.lastError = "Your settings could not be backed up, so nothing was reset.";
|
|
return;
|
|
}
|
|
root.lastError = "";
|
|
root.performReset();
|
|
});
|
|
if (!started) {
|
|
root.resetPending = false;
|
|
root.lastError = "Your settings could not be backed up, so nothing was reset.";
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
function performReset(): void {
|
|
root.setDisplayBlocked(true);
|
|
DesktopPreferences.resetDesktopDefaults();
|
|
|
|
// Do not apply geometry during a reset: doing so would need the same
|
|
// visible confirmation transaction as the Displays page. Clearing the
|
|
// stored records is still important, though, so the next session uses
|
|
// Panama's shipped DP-2 placement and automatic placement elsewhere.
|
|
|
|
// Home accessories keep their own store (panama-home.json), so a reset
|
|
// that only cleared the schema store would silently leave a customized
|
|
// favorites list behind while claiming to restore Panama's defaults.
|
|
//
|
|
// HomePreferences owns the write-through boundary so the state file is
|
|
// rewritten before this reset can be considered complete.
|
|
HomePreferences.resetHomeDefaults();
|
|
|
|
resettleTimer.restart();
|
|
}
|
|
|
|
Timer {
|
|
id: resettleTimer
|
|
interval: 60
|
|
onTriggered: {
|
|
root.applyPersistedDisplayPolicy();
|
|
root.reloadKeybinds();
|
|
root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));
|
|
root.regenerateLock();
|
|
resetRelease.attempts = 0;
|
|
resetRelease.restart();
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: resetRelease
|
|
property int attempts: 0
|
|
interval: 100
|
|
repeat: true
|
|
onTriggered: {
|
|
attempts++;
|
|
if ((!root.keybindsReloading() && !root.busy && !root.lockBusy()) || attempts >= 50) {
|
|
stop();
|
|
root.setDisplayBlocked(false);
|
|
}
|
|
}
|
|
}
|
|
|
|
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 });
|
|
}
|
|
|
|
// ── Color filters ───────────────────────────────────────────────────────
|
|
// The stored preference is an enum; what the compositor wants is a shader
|
|
// path. That mapping cannot be a `hypr:` block on the schema entry -- the
|
|
// read-back would compare "grayscale" against a filename and fail every
|
|
// shape and sweep contract -- so it lives here, and hypr/looks.lua does the
|
|
// same lookup for the value the config carries at launch.
|
|
//
|
|
// The shaders are installed by the hypr directory symlink, so the path is
|
|
// the deployed one rather than the repository's.
|
|
readonly property string shaderDir:
|
|
(Quickshell.env("XDG_CONFIG_HOME") || `${Quickshell.env("HOME")}/.config`) + "/hypr/shaders"
|
|
|
|
readonly property var colorFilterShaders: ({
|
|
"grayscale": "grayscale.frag",
|
|
"protanopia": "protanopia.frag",
|
|
"deuteranopia": "deuteranopia.frag",
|
|
"tritanopia": "tritanopia.frag"
|
|
})
|
|
|
|
// "" for none, and for any value this build does not ship a shader for --
|
|
// an unknown filter turns the filter off rather than leaving the previous
|
|
// one on under a new name.
|
|
function colorFilterPath(name: string): string {
|
|
const file = root.colorFilterShaders[String(name)];
|
|
return file === undefined ? "" : `${root.shaderDir}/${file}`;
|
|
}
|
|
|
|
property string colorFilterPending: ""
|
|
|
|
Process {
|
|
id: colorFilterWrite
|
|
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
// `hyprctl eval` exits 0 on a Lua error and reports it on
|
|
// stdout, so the exit status proves nothing. Read it back.
|
|
if (this.text.indexOf("error:") >= 0) {
|
|
root.lastError = "The color filter could not be applied.";
|
|
return;
|
|
}
|
|
colorFilterVerify.exec(["hyprctl", "-j", "getoption", "decoration:screen_shader"]);
|
|
}
|
|
}
|
|
}
|
|
|
|
Process {
|
|
id: colorFilterVerify
|
|
|
|
stdout: StdioCollector {
|
|
onStreamFinished: {
|
|
try {
|
|
const observed = String(JSON.parse(this.text).str ?? "");
|
|
if (observed !== root.colorFilterPending) {
|
|
root.lastError = "The compositor did not take the color filter.";
|
|
return;
|
|
}
|
|
root.lastError = "";
|
|
} catch (error) {
|
|
root.lastError = "The compositor did not say whether the color filter applied.";
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Applies the filter to the running compositor. The preference is written
|
|
// by the row that calls this; nothing here stores anything.
|
|
function applyColorFilter(name: string): void {
|
|
if (colorFilterWrite.running)
|
|
return;
|
|
const path = root.colorFilterPath(name);
|
|
root.colorFilterPending = path;
|
|
colorFilterWrite.exec(["hyprctl", "eval",
|
|
`hl.config({ decoration = { screen_shader = "${path.replace(/["\\]/g, "")}" } })`]);
|
|
}
|
|
|
|
// 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 {
|
|
// Verified against `gnome-control-center --list` on this system. A name
|
|
// that panel list does not contain opens nothing and reports an error,
|
|
// so guessing one here would be a silently dead button.
|
|
return [
|
|
"applications", "background", "bluetooth", "color", "display",
|
|
"keyboard", "mouse", "multitasking", "network", "notifications",
|
|
"online-accounts", "power", "printers", "privacy", "search",
|
|
"sharing", "sound", "system", "universal-access", "wacom",
|
|
"wellbeing", "wifi", "wwan"
|
|
].indexOf(panel) >= 0;
|
|
}
|
|
|
|
// `subpage` reaches the panels GNOME 50 nests under System -- users,
|
|
// about, datetime, region -- which its own desktop entries open as
|
|
// `gnome-control-center system users`. Without it, a row labeled "Users"
|
|
// lands on System's front page and leaves the user to navigate, which is
|
|
// most of the way to a broken button.
|
|
function openGnomePanel(panel: string, subpage: string): bool {
|
|
if (!root.isGnomePanelAllowed(panel)) {
|
|
root.lastError = "That GNOME Settings panel is not available.";
|
|
return false;
|
|
}
|
|
const command = ["gnome-control-center", panel];
|
|
if (subpage !== undefined && subpage !== "" && root.isGnomeSubpageAllowed(panel, subpage))
|
|
command.push(subpage);
|
|
Quickshell.execDetached({
|
|
command: command,
|
|
environment: { "XDG_CURRENT_DESKTOP": "GNOME" }
|
|
});
|
|
return true;
|
|
}
|
|
|
|
// Only System nests panels, and only these. Read off the Exec lines of the
|
|
// gnome-*-panel desktop entries rather than guessed, for the same reason
|
|
// the panel list above was.
|
|
function isGnomeSubpageAllowed(panel: string, subpage: string): bool {
|
|
if (panel !== "system")
|
|
return false;
|
|
return ["users", "about", "datetime", "region", "remote-desktop"].indexOf(subpage) >= 0;
|
|
}
|
|
|
|
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"],
|
|
// The Accessibility page's "Start Orca" button. It was calling
|
|
// openApplication("orca") with no "orca" here, so it set an error
|
|
// and launched nothing -- a dead button. orca ships in
|
|
// hyprland-packages, so this just runs it.
|
|
"orca": ["orca"]
|
|
};
|
|
const command = commands[id];
|
|
if (!command) {
|
|
root.lastError = "That application is not managed by Panama Settings.";
|
|
return false;
|
|
}
|
|
Quickshell.execDetached(command);
|
|
return true;
|
|
}
|
|
}
|