Files
Panama/config/dot/quickshell/services/DesktopStyle.qml
T
Gabriel Brown 6997dd535f Add high contrast, and make remote desktop configurable
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
2026-08-19 16:42:20 -04:00

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");
}
}