Measured the gap first: of the 38 real Hyprland options Panama's own Lua
sets, only 16 were editable in Settings. Everything else required a text
editor, which is the thing this app exists to stop. This closes most of
that: 66 mapped options now, from 47.
Window shape and shadows on Appearance: corner shape (rounding_power),
focused and fullscreen opacity, shadow falloff and hard-edged shadows.
Window edges, master layout and Hyprland's own notices on Desktop & Dock.
Three of these are corrections rather than additions.
Master layout options existed nowhere, while Settings has offered "Master
and stack" as a choice since this morning -- a layout you can select and
cannot configure is barely a choice. Its card is hidden unless that
layout is actually selected, since settings that do nothing under the
layout you are running are worse than not offering the layout at all.
The four Hyprland notices -- logo, splash, update news, donation nag --
are all turned off by looks.lua on the user's behalf. Defensible as a
default, but not a decision anyone could reverse. They are stored
positively ("show this") and written as Hyprland's `disable_*` through a
new `invert` flag, because a switch labelled "Disable splash text" that
must be ON to hide something is a small cruelty. The Lua does the same
inversion so both sides agree.
Everything new also reads from prefs in looks.lua. Without that these
would apply live and silently revert on the next compositor reload,
which is the failure this codebase keeps designing against.
Two shapes the write path had never seen. Border colours are gradients
and shadow offsets are vec2, and the verifier understood neither -- it
returned false for anything outside int/bool/float/str/css, so both
would have reported every write as rejected. Gradients also need real
care: the stubs declare them as `string|{colors,angle}`, and the string
form carries only ONE stop, so writing "rgba(a) rgba(b) 45deg" as a
string is accepted and keeps the previous value. Verified that directly.
They are also written in one notation and read back in another
(`{colors={"rgba(3b426199)"},angle=45}` becomes `993b4261 45deg`), so
comparison normalises both sides.
Border COLOUR is deliberately not exposed yet. col.inactive_border is
written by ColorScheme on every scheme change, so a user's choice would
be silently overwritten, and col.active_border is the Prism gradient,
which needs a colour control this app does not have. Shadow offset is
left out for the same reason -- the vec2 support is in place for
whenever the widget exists.
Verified each new option applies and reverts against the live
compositor, and that the schema, enum-map, nav, write and commit/reset
contracts all pass.
Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
640 lines
26 KiB
QML
640 lines
26 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: ""
|
|
|
|
// 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); }
|
|
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); }
|
|
|
|
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 ────────────────────────────────────────────────────
|
|
// 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 labelled "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;
|
|
}
|
|
|
|
// 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);
|
|
|
|
// 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 defence.
|
|
return `"${String(value).replace(/["\\]/g, "")}"`;
|
|
}
|
|
|
|
function serialiseTable(node: var): string {
|
|
const parts = [];
|
|
for (const name in node) {
|
|
const child = node[name];
|
|
// A leaf arrives pre-serialised as a string; anything else is
|
|
// either a nested section or a structured value (gradient, vec2)
|
|
// that serialiseValue knows how to render.
|
|
const rendered = typeof child === "string"
|
|
? child
|
|
: (Array.isArray(child) || (child && child.colors !== undefined)
|
|
? root.serialiseValue(child)
|
|
: root.serialiseTable(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.normaliseGradient(expected) === root.normaliseGradient(observed);
|
|
}
|
|
|
|
// Both notations reduced to "aarrggbb aarrggbb Ndeg".
|
|
function normaliseGradient(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);
|
|
}
|
|
|
|
// 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(): bool {
|
|
if (root.displayBusy()) {
|
|
root.lastError = "Finish the current display change before restoring defaults.";
|
|
return false;
|
|
}
|
|
|
|
const currentDisplays = root.readDisplays();
|
|
const protectedDisplays = JSON.parse(JSON.stringify(
|
|
currentDisplays && typeof currentDisplays === "object" ? currentDisplays : {}));
|
|
root.setDisplayBlocked(true);
|
|
DesktopPreferences.resetDesktopDefaults();
|
|
if (!root.protectDisplays(protectedDisplays)) {
|
|
root.setDisplayBlocked(false);
|
|
root.lastError = "The current display setting could not be protected during reset.";
|
|
return false;
|
|
}
|
|
|
|
// 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.
|
|
//
|
|
// HomePreferences owns the write-through boundary so the state file is
|
|
// rewritten before this reset can be considered complete.
|
|
HomePreferences.resetHomeDefaults();
|
|
|
|
resettleTimer.restart();
|
|
return true;
|
|
}
|
|
|
|
Timer {
|
|
id: resettleTimer
|
|
interval: 60
|
|
onTriggered: {
|
|
root.applyPersistedDisplayPolicy();
|
|
root.reloadKeybinds();
|
|
root.applyWallpaper(String(DesktopPreferences.get("wallpaperPath") ?? ""));
|
|
resetRelease.attempts = 0;
|
|
resetRelease.restart();
|
|
}
|
|
}
|
|
|
|
Timer {
|
|
id: resetRelease
|
|
property int attempts: 0
|
|
interval: 100
|
|
repeat: true
|
|
onTriggered: {
|
|
attempts++;
|
|
if ((!root.keybindsReloading() && !root.busy) || 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 });
|
|
}
|
|
|
|
// 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 labelled "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"]
|
|
};
|
|
const command = commands[id];
|
|
if (!command) {
|
|
root.lastError = "That application is not managed by Panama Settings.";
|
|
return false;
|
|
}
|
|
Quickshell.execDetached(command);
|
|
return true;
|
|
}
|
|
}
|