Files
Panama/config/dot/quickshell/services/DesktopStyle.qml
T

261 lines
9.7 KiB
QML

pragma Singleton
// Application-facing desktop style.
//
// Panama owns the durable choices; gsettings is an output boundary for GTK
// and applications that follow GNOME's desktop schemas. Commands are arrays,
// values are validated before storage, and no user text is ever sent through a
// shell. Hyprland's pointer setting stays live through Accessibility.
import Quickshell
import Quickshell.Io
import QtQuick
import qs.config
Singleton {
id: root
readonly property string helperPath: Quickshell.shellDir + "/scripts/panama-desktop-style"
// SearchPicker consumes [{ value, label, detail }]. Keep the raw names as
// a separate allow-list so a caller cannot smuggle a display label into a
// stored theme name.
property var cursorThemes: []
property var iconThemes: []
property var cursorThemeNames: []
property var iconThemeNames: []
property bool catalogLoaded: false
property bool scanning: false
property bool startupApplied: false
property string lastError: ""
property var pending: []
readonly property bool busy: root.scanning || catalogProcess.running
|| runner.running || root.pending.length > 0
readonly property int preferenceRevision: DesktopPreferences.revision
readonly property string cursorTheme: DesktopPreferences.get("cursorTheme")
readonly property string iconTheme: DesktopPreferences.get("iconTheme")
readonly property string applicationFont: DesktopPreferences.get("applicationFont")
readonly property string documentFont: DesktopPreferences.get("documentFont")
readonly property string monospaceFont: DesktopPreferences.get("monospaceFont")
Process {
id: catalogProcess
stdout: StdioCollector {
onStreamFinished: root.acceptCatalog(this.text)
}
onExited: (exitCode, exitStatus) => {
root.scanning = false;
if (exitCode !== 0) {
root.catalogLoaded = false;
root.lastError = "Installed icon and pointer themes could not be read.";
}
}
}
Process {
id: runner
onExited: (exitCode, exitStatus) => {
if (exitCode !== 0)
root.lastError = "One desktop style setting could not be applied.";
root.drain();
}
}
Component.onCompleted: {
root.ensureStarted();
// Accessing the singleton here keeps its existing hyprctl setcursor
// path alive for cursor-theme changes as well as cursor-size changes.
Accessibility.applyAll();
}
Timer {
id: startupApply
interval: 1200
onTriggered: {
root.startupApplied = true;
root.applyAll();
}
}
Connections {
target: DesktopPreferences
function onRevisionChanged(): void { applyCoalesce.restart(); }
}
Timer {
id: applyCoalesce
interval: 180
onTriggered: root.applyAll()
}
function ensureStarted(): void {
if (!root.catalogLoaded && !root.scanning)
root.refreshCatalog();
if (!root.startupApplied && !startupApply.running)
startupApply.restart();
}
function refreshCatalog(): void {
if (catalogProcess.running)
return;
root.scanning = true;
catalogProcess.exec([root.helperPath]);
}
function acceptCatalog(text: string): void {
try {
const parsed = JSON.parse(text);
if (!parsed || !Array.isArray(parsed.cursorThemes) || !Array.isArray(parsed.iconThemes))
throw new Error("invalid catalog shape");
const cursors = parsed.cursorThemes.filter(name =>
typeof name === "string" && PreferenceSchema.coerce("cursorTheme", name) !== undefined);
const icons = parsed.iconThemes.filter(name =>
typeof name === "string" && PreferenceSchema.coerce("iconTheme", name) !== undefined);
root.cursorThemeNames = cursors;
root.iconThemeNames = icons;
root.cursorThemes = cursors.map(name => ({
value: name,
label: name,
detail: "Pointer theme"
}));
root.iconThemes = icons.map(name => ({
value: name,
label: name,
detail: "Application icon theme"
}));
root.catalogLoaded = true;
root.lastError = "";
} catch (error) {
root.cursorThemes = [];
root.iconThemes = [];
root.cursorThemeNames = [];
root.iconThemeNames = [];
root.catalogLoaded = false;
root.lastError = "Installed icon and pointer themes could not be read.";
}
root.scanning = false;
}
function drain(): void {
if (runner.running || root.pending.length === 0)
return;
const next = root.pending[0];
root.pending = root.pending.slice(1);
runner.exec(next);
}
function enqueue(commands: var): void {
// A fresh revision supersedes commands that have not started yet. The
// currently running command is allowed to finish, then the newest full
// state is replayed in a deterministic order.
root.pending = commands;
root.drain();
}
// GVariant accepts JSON-style quoted strings. JSON.stringify escapes every
// quote, backslash, and control character, and the schema patterns further
// constrain stored font/theme names. Arguments still travel directly to
// gsettings rather than through a shell.
function gvariant(value: var): string {
if (typeof value === "boolean")
return value ? "true" : "false";
if (typeof value === "number")
return String(value);
return JSON.stringify(String(value));
}
function fontName(familyKey: string, sizeKey: string): string {
return `${DesktopPreferences.get(familyKey)} ${DesktopPreferences.get(sizeKey)}`;
}
function buttonLayout(): string {
const side = DesktopPreferences.get("titlebarButtonSide");
const maximize = DesktopPreferences.get("titlebarMaximizeButton") === true;
// Tokens are fixed. Only their side and whether maximize is present
// vary, so preference data can never become command syntax.
if (side === "left")
return (maximize ? "close,maximize" : "close") + ":appmenu";
return "appmenu:" + (maximize ? "maximize,close" : "close");
}
function setting(schema: string, key: string, value: var): var {
return ["gsettings", "set", schema, key, root.gvariant(value)];
}
function applyAll(): void {
root.lastError = "";
root.enqueue([
root.setting("org.gnome.desktop.interface", "icon-theme", root.iconTheme),
root.setting("org.gnome.desktop.interface", "cursor-theme", root.cursorTheme),
root.setting("org.gnome.desktop.interface", "font-name",
root.fontName("applicationFont", "applicationFontSize")),
root.setting("org.gnome.desktop.interface", "document-font-name",
root.fontName("documentFont", "documentFontSize")),
root.setting("org.gnome.desktop.interface", "monospace-font-name",
root.fontName("monospaceFont", "monospaceFontSize")),
root.setting("org.gnome.desktop.interface", "font-hinting",
DesktopPreferences.get("fontHinting")),
root.setting("org.gnome.desktop.interface", "font-antialiasing",
DesktopPreferences.get("fontAntialiasing")),
root.setting("org.gnome.desktop.interface", "gtk-enable-primary-paste",
DesktopPreferences.get("middleClickPaste")),
root.setting("org.gnome.desktop.wm.preferences", "button-layout", root.buttonLayout()),
root.setting("org.gnome.desktop.wm.preferences", "action-double-click-titlebar",
DesktopPreferences.get("titlebarDoubleClick"))
]);
}
function storeCatalogChoice(key: string, value: string, allowed: var, kind: string): bool {
if (!root.catalogLoaded || allowed.indexOf(value) < 0) {
root.lastError = `That ${kind} theme is not installed.`;
return false;
}
if (!DesktopPreferences.set(key, value)) {
root.lastError = `That ${kind} theme name could not be saved.`;
return false;
}
root.lastError = "";
return true;
}
function setCursorTheme(value: string): bool {
return root.storeCatalogChoice("cursorTheme", value, root.cursorThemeNames, "pointer");
}
function setIconTheme(value: string): bool {
return root.storeCatalogChoice("iconTheme", value, root.iconThemeNames, "icon");
}
function storeFont(key: string, family: string, allowed: var, kind: string): bool {
if (allowed.indexOf(family) < 0) {
root.lastError = `That ${kind} font is not installed.`;
return false;
}
if (!DesktopPreferences.set(key, family)) {
root.lastError = `That ${kind} font name could not be saved.`;
return false;
}
root.lastError = "";
return true;
}
function setApplicationFont(family: string): bool {
return root.storeFont("applicationFont", family, Fonts.interfaceFonts, "application");
}
function setDocumentFont(family: string): bool {
return root.storeFont("documentFont", family, Fonts.interfaceFonts, "document");
}
function setMonospaceFont(family: string): bool {
return root.storeFont("monospaceFont", family, Fonts.monospaceFonts, "monospace");
}
}