Add effective desktop style controls
This commit is contained in:
@@ -0,0 +1,260 @@
|
||||
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");
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user