261 lines
9.7 KiB
QML
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");
|
|
}
|
|
}
|