Two of the three panels still handed to GNOME, having actually checked each rather than repeating that they were not worth owning. Universal Access turned out to be mostly ours already: the magnifier, pointer size, text scale, motion and dimming were all present. High contrast was the real gap. It reaches GTK4 applications through the desktop portal, which republishes GNOME's accessibility setting as org.freedesktop.appearance contrast -- so no high-contrast theme is involved, and none is installed here. Verified end to end: committing the preference drove gsettings and the portal reported contrast 1. Sticky, slow and bounce keys stay absent. There is no Wayland or Hyprland implementation, and the compositor would store the XKB option while nothing ever acted on it. Remote desktop gained port, view-only, and clearing stored credentials. SETTING credentials opens a terminal running grdctl, which prompts for the password itself. That is not a hand-off for lack of effort: grdctl takes the password on a terminal and core-dumps without one, and the only alternative -- passing it as an argument -- would publish it through /proc to every process on this machine. Typed into grdctl directly it never passes through Panama, and a contract now fails if it ever appears on a command line. Color stays with GNOME, and not for lack of effort either. colord runs here with seven profiles and zero devices registered, because the daemons that register displays do not run under this session, and Hyprland exposes no ICC, gamma, or color-management option at all. A Color page could import a profile, attach it to nothing, and change nothing -- the same failure refused for rollback and printer drivers. Claude-Session: https://claude.ai/code/session_01BRvzt4H8XXLPVH5MyYdk9L
266 lines
10 KiB
QML
266 lines
10 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")),
|
|
// The portal republishes this as org.freedesktop.appearance
|
|
// contrast, which is what libadwaita reads -- so this reaches GTK4
|
|
// applications without any high-contrast theme being installed.
|
|
root.setting("org.gnome.desktop.a11y.interface", "high-contrast",
|
|
DesktopPreferences.get("highContrast"))
|
|
]);
|
|
}
|
|
|
|
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");
|
|
}
|
|
}
|