Add effective desktop style controls

This commit is contained in:
Gabriel Brown
2026-08-18 12:57:54 -04:00
parent f9eba1e8c5
commit df1dcdfad5
10 changed files with 837 additions and 29 deletions
+3 -1
View File
@@ -9,6 +9,8 @@
-- instead. They still work here; see the session notes in autostart.lua. -- instead. They still work here; see the session notes in autostart.lua.
-- ───────────────────────────────────────────────────────────────────────────── -- ─────────────────────────────────────────────────────────────────────────────
local prefs = require("prefs")
-- ── GPU selection ─────────────────────────────────────────────────────────── -- ── GPU selection ───────────────────────────────────────────────────────────
-- This box has a discrete RX 7800 XT (0000:03:00.0) and a Granite Ridge iGPU -- This box has a discrete RX 7800 XT (0000:03:00.0) and a Granite Ridge iGPU
-- (0000:12:00.0). The monitor hangs off the dGPU, so the dGPU must render. -- (0000:12:00.0). The monitor hangs off the dGPU, so the dGPU must render.
@@ -47,7 +49,7 @@ end
-- (it has cursors/ + index.theme and no hyprcursors/ directory or manifest.hl), -- (it has cursors/ + index.theme and no hyprcursors/ directory or manifest.hl),
-- so setting it would point hyprcursor at nothing. Hyprland falls back to the -- so setting it would point hyprcursor at nothing. Hyprland falls back to the
-- XCursor path, which is what we want. -- XCursor path, which is what we want.
hl.env("XCURSOR_THEME", "oreo_blue_cursors") hl.env("XCURSOR_THEME", prefs.get("cursorTheme", "oreo_blue_cursors"))
hl.env("XCURSOR_SIZE", "24") hl.env("XCURSOR_SIZE", "24")
-- ── Toolkits ──────────────────────────────────────────────────────────────── -- ── Toolkits ────────────────────────────────────────────────────────────────
+5
View File
@@ -144,6 +144,11 @@ hl.config({
disable_hyprland_logo = not prefs.get("hyprlandLogo", false), disable_hyprland_logo = not prefs.get("hyprlandLogo", false),
disable_splash_rendering = not prefs.get("hyprlandSplash", false), disable_splash_rendering = not prefs.get("hyprlandSplash", false),
-- Keep native Wayland selection paste and GTK's matching preference
-- in lockstep. DesktopStyle applies the GTK half only after this value
-- has been read back and stored by SystemSettings.
middle_click_paste = prefs.get("middleClickPaste", true),
-- Same setting as Theme.fontFamily in the shell. If only the QML side -- Same setting as Theme.fontFamily in the shell. If only the QML side
-- followed the preference, the compositor and the shell would disagree -- followed the preference, the compositor and the shell would disagree
-- about the interface font and nobody would notice until a tooltip -- about the interface font and nobody would notice until a tooltip
@@ -548,6 +548,12 @@ Singleton {
detail: "Swap the primary and secondary buttons", detail: "Swap the primary and secondary buttons",
hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" } hypr: { path: ["input", "left_handed"], option: "input:left_handed", readAs: "bool" }
}, },
{
key: "middleClickPaste", type: "bool", def: true, group: "pointer",
label: "Middle-click paste",
detail: "Paste the primary selection in GTK and native Wayland applications",
hypr: { path: ["misc", "middle_click_paste"], option: "misc:middle_click_paste", readAs: "bool" }
},
// ── Touchpad ──────────────────────────────────────────────────────── // ── Touchpad ────────────────────────────────────────────────────────
// //
@@ -806,6 +812,24 @@ Singleton {
] ]
}, },
// ── Application themes ─────────────────────────────────────────────
// ColorScheme owns GTK's light/dark theme. These are the two theme
// choices GNOME applications expose independently of that palette:
// their icons and pointer. DesktopStyle only accepts names found in
// the read-only XDG catalog before storing them.
{
key: "cursorTheme", type: "string", def: "oreo_blue_cursors", group: "themes",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Pointer theme",
detail: "The pointer design used by applications and Hyprland"
},
{
key: "iconTheme", type: "string", def: "Adwaita", group: "themes",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Application icons",
detail: "The icon set used by GTK applications"
},
// ── Typography ────────────────────────────────────────────────────── // ── Typography ──────────────────────────────────────────────────────
// The single largest thing in this desktop that used to be changeable // The single largest thing in this desktop that used to be changeable
// only by editing Theme.qml. // only by editing Theme.qml.
@@ -835,6 +859,91 @@ Singleton {
label: "Interface text size", label: "Interface text size",
detail: "The base size the rest of the shell's type scales from" detail: "The base size the rest of the shell's type scales from"
}, },
{
key: "applicationFont", type: "string", def: "Adwaita Sans", group: "typography",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Application font",
detail: "Used by menus, controls, and labels in applications"
},
{
key: "applicationFontSize", type: "int", def: 11, min: 6, max: 32, step: 1,
unit: "pt", group: "typography",
label: "Application text size",
detail: "The base text size used by applications"
},
{
key: "documentFont", type: "string", def: "Adwaita Sans", group: "typography",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Document font",
detail: "Used for document content when an application follows the system choice"
},
{
key: "documentFontSize", type: "int", def: 12, min: 6, max: 32, step: 1,
unit: "pt", group: "typography",
label: "Document text size",
detail: "The default text size for document content"
},
{
key: "monospaceFont", type: "string", def: "VictorMono Nerd Font", group: "typography",
pattern: "^[A-Za-z0-9 ._+@'-]{1,96}$",
label: "Monospace font",
detail: "Used by terminals, editors, and code fields that follow the system choice"
},
{
key: "monospaceFontSize", type: "int", def: 10, min: 6, max: 32, step: 1,
unit: "pt", group: "typography",
label: "Monospace text size",
detail: "The default text size for terminals and code"
},
{
key: "fontHinting", type: "enum", def: "slight", group: "typography",
label: "Font hinting",
detail: "How strongly text aligns to the pixel grid",
options: [
{ value: "none", label: "None" },
{ value: "slight", label: "Slight" },
{ value: "medium", label: "Medium" },
{ value: "full", label: "Full" }
]
},
{
key: "fontAntialiasing", type: "enum", def: "rgba", group: "typography",
label: "Text smoothing",
detail: "How application text softens its edges",
options: [
{ value: "none", label: "None" },
{ value: "grayscale", label: "Grayscale" },
{ value: "rgba", label: "Subpixel" }
]
},
// ── Application titlebars ──────────────────────────────────────────
// These affect applications that honour GNOME's window preferences.
// Hyprland itself has no server-side titlebar buttons, so minimize is
// deliberately absent rather than presented as a switch that lies.
{
key: "titlebarButtonSide", type: "enum", def: "right", group: "titlebar",
label: "Button side",
detail: "Place application titlebar buttons on the left or right",
options: [
{ value: "left", label: "Left" },
{ value: "right", label: "Right" }
]
},
{
key: "titlebarMaximizeButton", type: "bool", def: false, group: "titlebar",
label: "Maximize button",
detail: "Show a maximize button in application titlebars that support it"
},
{
key: "titlebarDoubleClick", type: "enum", def: "toggle-maximize", group: "titlebar",
label: "Double-click titlebar",
detail: "Choose what a double-click on an application titlebar does",
options: [
{ value: "toggle-maximize", label: "Toggle maximize" },
{ value: "none", label: "Do nothing" }
]
},
// ── Accessibility ─────────────────────────────────────────────────── // ── Accessibility ───────────────────────────────────────────────────
// Backed by gsettings so GTK applications agree with the shell, and // Backed by gsettings so GTK applications agree with the shell, and
@@ -17,8 +17,10 @@ import qs.services
SettingsPage { SettingsPage {
id: root id: root
property string expandedPicker: ""
title: "Appearance" title: "Appearance"
lede: "Drag anything below. The preview above is your real geometry, to scale." lede: "Tune the Prism shell and the applications that live inside it. The preview above is your real geometry, to scale."
header: Component { header: Component {
Column { Column {
@@ -83,7 +85,7 @@ SettingsPage {
} }
SettingsCard { SettingsCard {
title: "Typography" title: "Shell typography"
subtitle: Fonts.lastError !== "" subtitle: Fonts.lastError !== ""
? Fonts.lastError ? Fonts.lastError
: "Every piece of text in the shell. Samples are drawn in the font they name." : "Every piece of text in the shell. Samples are drawn in the font they name."
@@ -123,6 +125,137 @@ SettingsPage {
} }
} }
SettingsCard {
title: "Application typography"
subtitle: DesktopStyle.lastError !== ""
? DesktopStyle.lastError
: "Fonts used by applications that follow the desktop defaults. Open one family at a time to keep the page calm."
ActionRow {
label: "Application font"
detail: DesktopStyle.applicationFont
action: root.expandedPicker === "application-font" ? "Close" : "Choose"
onTriggered: root.expandedPicker = root.expandedPicker === "application-font" ? "" : "application-font"
}
FontPicker {
visible: root.expandedPicker === "application-font"
width: parent.width
families: Fonts.interfaceFonts
current: DesktopStyle.applicationFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No application fonts found"
onPicked: family => {
if (DesktopStyle.setApplicationFont(family))
root.expandedPicker = "";
}
}
SliderRow { setting: "applicationFontSize" }
ActionRow {
label: "Document font"
detail: DesktopStyle.documentFont
action: root.expandedPicker === "document-font" ? "Close" : "Choose"
onTriggered: root.expandedPicker = root.expandedPicker === "document-font" ? "" : "document-font"
}
FontPicker {
visible: root.expandedPicker === "document-font"
width: parent.width
families: Fonts.interfaceFonts
current: DesktopStyle.documentFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No document fonts found"
onPicked: family => {
if (DesktopStyle.setDocumentFont(family))
root.expandedPicker = "";
}
}
SliderRow { setting: "documentFontSize" }
ActionRow {
label: "Monospace font"
detail: DesktopStyle.monospaceFont
action: root.expandedPicker === "monospace-font" ? "Close" : "Choose"
onTriggered: root.expandedPicker = root.expandedPicker === "monospace-font" ? "" : "monospace-font"
}
FontPicker {
visible: root.expandedPicker === "monospace-font"
width: parent.width
families: Fonts.monospaceFonts
current: DesktopStyle.monospaceFont
emptyText: Fonts.scanning ? "Reading installed fonts…" : "No monospace fonts found"
onPicked: family => {
if (DesktopStyle.setMonospaceFont(family))
root.expandedPicker = "";
}
}
SliderRow { setting: "monospaceFontSize" }
ChoiceRow { setting: "fontHinting" }
ChoiceRow { setting: "fontAntialiasing"; divider: false }
}
SettingsCard {
title: "Icons & pointer"
subtitle: DesktopStyle.lastError !== ""
? DesktopStyle.lastError
: "Installed themes only. The pointer updates in applications and Hyprland together."
ActionRow {
label: "Application icons"
detail: DesktopStyle.iconTheme
action: root.expandedPicker === "icon-theme" ? "Close" : "Choose"
enabled: DesktopStyle.catalogLoaded
onTriggered: root.expandedPicker = root.expandedPicker === "icon-theme" ? "" : "icon-theme"
}
SearchPicker {
visible: root.expandedPicker === "icon-theme"
width: parent.width
items: DesktopStyle.iconThemes
current: DesktopStyle.iconTheme
placeholder: "Search icon themes"
emptyText: DesktopStyle.scanning ? "Reading installed icon themes…" : "No icon themes found"
onPicked: value => {
if (DesktopStyle.setIconTheme(value))
root.expandedPicker = "";
}
}
ActionRow {
label: "Pointer theme"
detail: DesktopStyle.cursorTheme
action: root.expandedPicker === "cursor-theme" ? "Close" : "Choose"
enabled: DesktopStyle.catalogLoaded
divider: false
onTriggered: root.expandedPicker = root.expandedPicker === "cursor-theme" ? "" : "cursor-theme"
}
SearchPicker {
visible: root.expandedPicker === "cursor-theme"
width: parent.width
items: DesktopStyle.cursorThemes
current: DesktopStyle.cursorTheme
placeholder: "Search pointer themes"
emptyText: DesktopStyle.scanning ? "Reading installed pointer themes…" : "No pointer themes found"
onPicked: value => {
if (DesktopStyle.setCursorTheme(value))
root.expandedPicker = "";
}
}
}
SettingsCard {
title: "Titlebars"
subtitle: "For applications that draw GNOME-compatible titlebars. Hyprland itself does not add titlebar buttons to tiled windows."
ChoiceRow { setting: "titlebarButtonSide" }
ToggleRow { setting: "titlebarMaximizeButton" }
ChoiceRow { setting: "titlebarDoubleClick"; divider: false }
}
SettingsCard { SettingsCard {
title: "Windows" title: "Windows"
subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved." subtitle: "Spacing and shape of tiled windows. Each change is applied to the compositor and confirmed before it is saved."
@@ -187,11 +320,4 @@ SettingsPage {
} }
} }
SettingsCard {
title: "Theme"
subtitle: "This desktop has one curated visual identity rather than a matrix of partially compatible themes. The controls above adjust its parameters — how much space, how soft, how much motion — without replacing it."
TextRow { label: "Color palette"; detail: "Tokyo Night Moon"; value: "Prism" }
TextRow { label: "Interface type"; detail: "Adwaita Sans"; value: "System"; divider: false }
}
} }
@@ -31,7 +31,12 @@ SettingsPage {
ChoiceRow { setting: "accelProfile" } ChoiceRow { setting: "accelProfile" }
ToggleRow { setting: "naturalScroll" } ToggleRow { setting: "naturalScroll" }
SliderRow { setting: "scrollFactor" } SliderRow { setting: "scrollFactor" }
ToggleRow { setting: "leftHanded"; divider: false } ToggleRow { setting: "leftHanded" }
ToggleRow {
setting: "middleClickPaste"
detail: "Paste the primary selection in GTK and native Wayland applications; individual apps may choose not to support it"
divider: false
}
} }
SettingsCard { SettingsCard {
+98
View File
@@ -0,0 +1,98 @@
#!/usr/bin/env python3
"""Report installed cursor and icon themes from the standard XDG roots.
This helper is intentionally read-only and argument-free. Theme paths come
only from XDG_DATA_HOME and XDG_DATA_DIRS; directory symlinks are not followed.
"""
from __future__ import annotations
import json
import os
import stat
import sys
from pathlib import Path
def icon_roots() -> list[Path]:
home = Path(os.environ.get("HOME") or "/nonexistent")
data_home = Path(os.environ.get("XDG_DATA_HOME") or home / ".local/share")
data_dirs = os.environ.get("XDG_DATA_DIRS") or "/usr/local/share:/usr/share"
roots = [data_home / "icons"]
roots.extend(Path(directory) / "icons" for directory in data_dirs.split(":") if directory)
# XDG paths are required to be absolute. Ignoring malformed relative
# entries also prevents the helper's working directory becoming an
# accidental caller-controlled search root.
return [root for root in roots if root.is_absolute()]
def is_real_directory(path: Path) -> bool:
try:
return stat.S_ISDIR(path.lstat().st_mode)
except OSError:
return False
def is_real_file(path: Path) -> bool:
try:
return stat.S_ISREG(path.lstat().st_mode)
except OSError:
return False
def has_icon_directories(index_path: Path) -> bool:
try:
with index_path.open(encoding="utf-8", errors="replace") as handle:
for raw_line in handle:
line = raw_line.strip()
if line.startswith(("#", ";")) or "=" not in line:
continue
key, value = line.split("=", 1)
if key.strip() == "Directories":
return bool(value.strip())
except OSError:
return False
return False
def catalog() -> dict[str, list[str]]:
cursor_themes: set[str] = set()
icon_themes: set[str] = set()
for root in icon_roots():
if not is_real_directory(root):
continue
try:
entries = list(os.scandir(root))
except OSError:
continue
for entry in entries:
if not entry.is_dir(follow_symlinks=False):
continue
theme = Path(entry.path)
if is_real_directory(theme / "cursors"):
cursor_themes.add(entry.name)
index_path = theme / "index.theme"
if is_real_file(index_path) and has_icon_directories(index_path):
icon_themes.add(entry.name)
return {
"cursorThemes": sorted(cursor_themes, key=str.casefold),
"iconThemes": sorted(icon_themes, key=str.casefold),
}
def main() -> int:
if len(sys.argv) != 1:
print("panama-desktop-style takes no arguments", file=sys.stderr)
return 2
print(json.dumps(catalog(), ensure_ascii=False, separators=(",", ":")))
return 0
if __name__ == "__main__":
raise SystemExit(main())
@@ -25,25 +25,14 @@ import qs.config
Singleton { Singleton {
id: root id: root
property string cursorTheme: ""
property string lastError: "" property string lastError: ""
readonly property bool busy: themeQuery.running || runner.running || root.pending.length > 0 readonly property bool busy: runner.running || root.pending.length > 0
readonly property string cursorTheme: DesktopPreferences.get("cursorTheme")
readonly property int cursorSize: DesktopPreferences.get("cursorSize") readonly property int cursorSize: DesktopPreferences.get("cursorSize")
readonly property real textScale: DesktopPreferences.get("textScale") readonly property real textScale: DesktopPreferences.get("textScale")
Process {
id: themeQuery
command: ["gsettings", "get", "org.gnome.desktop.interface", "cursor-theme"]
stdout: StdioCollector {
onStreamFinished: {
// gsettings quotes strings: 'oreo_blue_cursors'
root.cursorTheme = this.text.trim().replace(/^'|'$/g, "");
}
}
}
// A short queue, because applying one setting takes several commands and // A short queue, because applying one setting takes several commands and
// Process runs one at a time. // Process runs one at a time.
property var pending: [] property var pending: []
@@ -71,7 +60,6 @@ Singleton {
} }
Component.onCompleted: { Component.onCompleted: {
themeQuery.running = true;
settle.restart(); settle.restart();
} }
@@ -102,9 +90,6 @@ Singleton {
["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size], ["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size],
["gsettings", "set", "org.gnome.desktop.interface", "text-scaling-factor", String(root.textScale)] ["gsettings", "set", "org.gnome.desktop.interface", "text-scaling-factor", String(root.textScale)]
]; ];
// setcursor needs a theme name; skip it rather than guess if gsettings
// has not answered yet. The next change will catch up.
if (root.cursorTheme !== "")
commands.push(["hyprctl", "setcursor", root.cursorTheme, size]); commands.push(["hyprctl", "setcursor", root.cursorTheme, size]);
root.enqueue(commands); root.enqueue(commands);
} }
@@ -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");
}
}
@@ -17,12 +17,20 @@ import qs.config
Singleton { Singleton {
id: root id: root
// SettingsSearch is part of the always-constructed sidebar. Touching the
// desktop-style service here gives application preferences their startup
// replay even when Appearance is not the page that opens first.
Component.onCompleted: DesktopStyle.ensureStarted()
// Which page shows the settings in a given schema group. A group with no // Which page shows the settings in a given schema group. A group with no
// entry here still appears in results and routes to Home rather than being // entry here still appears in results and routes to Home rather than being
// dropped, so adding a group can never make a setting unreachable. // dropped, so adding a group can never make a setting unreachable.
readonly property var groupPages: ({ readonly property var groupPages: ({
"clock": "appearance", "clock": "appearance",
"vitals": "appearance", "vitals": "appearance",
"typography": "appearance",
"themes": "appearance",
"titlebar": "appearance",
"windows": "appearance", "windows": "appearance",
"effects": "appearance", "effects": "appearance",
"wallpaper": "appearance", "wallpaper": "appearance",
+210
View File
@@ -0,0 +1,210 @@
#!/usr/bin/env bash
# Desktop style stays split across three boundaries that can all be checked
# without starting the shell: the read-only icon catalog, the preference/schema
# wiring, and the QML surfaces that consume it. The catalog runs only against
# disposable XDG roots so this contract never reads or changes the live desktop.
set -euo pipefail
repo_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
helper="$repo_dir/config/dot/quickshell/scripts/panama-desktop-style"
service="$repo_dir/config/dot/quickshell/services/DesktopStyle.qml"
schema="$repo_dir/config/dot/quickshell/config/PreferenceSchema.qml"
appearance="$repo_dir/config/dot/quickshell/modules/settings/AppearancePage.qml"
mouse="$repo_dir/config/dot/quickshell/modules/settings/MousePage.qml"
accessibility="$repo_dir/config/dot/quickshell/services/Accessibility.qml"
search="$repo_dir/config/dot/quickshell/services/SettingsSearch.qml"
env_lua="$repo_dir/config/dot/hypr/env.lua"
looks_lua="$repo_dir/config/dot/hypr/looks.lua"
fail() {
printf 'desktop style contract: %s\n' "$1" >&2
exit 1
}
for file in "$helper" "$service" "$schema" "$appearance" "$mouse" \
"$accessibility" "$search" "$env_lua" "$looks_lua"; do
[[ -f "$file" ]] || fail "missing ${file#"$repo_dir/"}"
done
[[ -x "$helper" ]] || fail 'desktop-style catalog is not executable'
# ── Read-only catalog semantics ─────────────────────────────────────────────
fixture="$(mktemp -d /tmp/panama-desktop-style.XXXXXX)"
trap 'rm -rf "$fixture"' EXIT
home="$fixture/home"
data_home="$fixture/data-home"
data_one="$fixture/data-one"
data_two="$fixture/data-two"
mkdir -p "$home" \
"$data_home/icons/CursorOnly/cursors" \
"$data_one/icons/IconOnly/16x16/apps" \
"$data_one/icons/Both/cursors" \
"$data_one/icons/Both/scalable/apps" \
"$data_one/icons/EmptyDirectories" \
"$data_two/icons/Both/cursors" \
"$data_two/icons/Both/scalable/apps" \
"$data_two/icons/NoIndex/16x16/apps"
cat >"$data_home/icons/CursorOnly/index.theme" <<'EOF'
[Icon Theme]
Name=Cursor only
Directories=
EOF
cat >"$data_one/icons/IconOnly/index.theme" <<'EOF'
[Icon Theme]
Name=Icon only
Directories=16x16/apps
EOF
cat >"$data_one/icons/Both/index.theme" <<'EOF'
[Icon Theme]
Name=Both
Directories=scalable/apps
EOF
cat >"$data_one/icons/EmptyDirectories/index.theme" <<'EOF'
[Icon Theme]
Name=Not an icon catalog entry
Directories=
EOF
cp "$data_one/icons/Both/index.theme" "$data_two/icons/Both/index.theme"
catalog="$(
HOME="$home" \
XDG_DATA_HOME="$data_home" \
XDG_DATA_DIRS="$data_one:$data_two" \
"$helper"
)" || fail 'catalog helper failed against disposable XDG roots'
jq -e '
type == "object"
and (keys | sort) == ["cursorThemes", "iconThemes"]
and .cursorThemes == ["Both", "CursorOnly"]
and .iconThemes == ["Both", "IconOnly"]
and (all(.cursorThemes[]; type == "string" and length > 0))
and (all(.iconThemes[]; type == "string" and length > 0))
' <<<"$catalog" >/dev/null \
|| fail "catalog JSON shape or theme classification is wrong: $catalog"
if HOME="$home" XDG_DATA_HOME="$data_home" XDG_DATA_DIRS="$data_one:$data_two" \
"$helper" "$fixture/not-an-xdg-root" >/dev/null 2>&1; then
fail 'catalog accepted a caller-supplied path'
fi
# ── Schema, compositor replay, service, and UI wiring ───────────────────────
python3 - "$schema" <<'PY' || fail 'desktop-style schema entries are missing or malformed'
import re
import sys
text = open(sys.argv[1], encoding="utf-8").read()
expected = {
"cursorTheme": ("string", '"oreo_blue_cursors"', "themes"),
"iconTheme": ("string", '"Adwaita"', "themes"),
"applicationFont": ("string", '"Adwaita Sans"', "typography"),
"applicationFontSize": ("int", "11", "typography"),
"documentFont": ("string", '"Adwaita Sans"', "typography"),
"documentFontSize": ("int", "12", "typography"),
"monospaceFont": ("string", '"VictorMono Nerd Font"', "typography"),
"monospaceFontSize": ("int", "10", "typography"),
"fontHinting": ("enum", '"slight"', "typography"),
"fontAntialiasing": ("enum", '"rgba"', "typography"),
"titlebarButtonSide": ("enum", '"right"', "titlebar"),
"titlebarMaximizeButton": ("bool", "false", "titlebar"),
"titlebarDoubleClick": ("enum", '"toggle-maximize"', "titlebar"),
"middleClickPaste": ("bool", "true", "pointer"),
}
def block_for(key: str) -> str:
match = re.search(r"\{\s*\n\s*key:\s*\"" + re.escape(key) + r"\".*?\n\s{8}\}", text, re.S)
if not match:
raise SystemExit(f"missing {key}")
return match.group(0)
for key, (kind, default, group) in expected.items():
block = block_for(key)
if not re.search(rf'type:\s*"{re.escape(kind)}"', block):
raise SystemExit(f"{key} has wrong type")
if not re.search(rf'def:\s*{re.escape(default)}', block):
raise SystemExit(f"{key} has wrong default")
if not re.search(rf'group:\s*"{re.escape(group)}"', block):
raise SystemExit(f"{key} has wrong group")
enum_values = {
"fontHinting": ["none", "slight", "medium", "full"],
"fontAntialiasing": ["none", "grayscale", "rgba"],
"titlebarButtonSide": ["left", "right"],
"titlebarDoubleClick": ["toggle-maximize", "none"],
}
for key, values in enum_values.items():
block = block_for(key)
actual = re.findall(r'value:\s*"([^"]+)"', block)
if actual != values:
raise SystemExit(f"{key} options are {actual}, expected {values}")
middle = block_for("middleClickPaste")
for fragment in (
'path: ["misc", "middle_click_paste"]',
'option: "misc:middle_click_paste"',
'readAs: "bool"',
):
if fragment not in middle:
raise SystemExit(f"middleClickPaste is missing {fragment}")
PY
rg -q 'prefs\.get\("cursorTheme", "oreo_blue_cursors"\)' "$env_lua" \
|| fail 'Hyprland environment does not replay cursorTheme'
rg -q 'middle_click_paste\s*=\s*prefs\.get\("middleClickPaste", true\)' "$looks_lua" \
|| fail 'Hyprland misc does not replay middleClickPaste'
rg -q 'DesktopPreferences\.get\("cursorTheme"\)' "$accessibility" \
|| fail 'Accessibility does not use the stored cursor theme'
! rg -q 'gsettings.*cursor-theme|themeQuery' "$accessibility" \
|| fail 'Accessibility still queries cursor-theme from gsettings'
for needle in \
'property var cursorThemes' \
'property var iconThemes' \
'DesktopPreferences.revision' \
'Fonts.interfaceFonts' \
'Fonts.monospaceFonts' \
'gtk-enable-primary-paste' \
'button-layout' \
'action-double-click-titlebar'; do
rg -Fq "$needle" "$service" || fail "DesktopStyle is missing $needle"
done
! rg -q 'sh -c|bash -c' "$service" \
|| fail 'DesktopStyle routes gsettings through a shell'
for group in typography themes titlebar; do
rg -q "\"$group\": \"appearance\"" "$search" \
|| fail "settings search does not route $group to Appearance"
done
rg -q '"pointer": "mouse"' "$search" \
|| fail 'settings search no longer routes pointer controls to Mouse'
for needle in \
'title: "Application typography"' \
'setting: "applicationFontSize"' \
'setting: "documentFontSize"' \
'setting: "monospaceFontSize"' \
'setting: "fontHinting"' \
'setting: "fontAntialiasing"' \
'title: "Icons & pointer"' \
'DesktopStyle.cursorThemes' \
'DesktopStyle.iconThemes' \
'title: "Titlebars"' \
'setting: "titlebarButtonSide"' \
'setting: "titlebarMaximizeButton"' \
'setting: "titlebarDoubleClick"'; do
rg -Fq "$needle" "$appearance" || fail "Appearance is missing $needle"
done
! rg -q 'setting: "titlebarMinimize|GTK theme|Shell theme' "$appearance" \
|| fail 'Appearance exposes an inert or separately-owned theme control'
rg -q 'setting: "middleClickPaste"' "$mouse" \
|| fail 'Mouse does not expose middle-click paste'
rg -q 'GTK.*Wayland|Wayland.*GTK' "$mouse" \
|| fail 'Mouse does not explain the GTK and Wayland scope honestly'
printf 'desktop style contract: PASS\n'