diff --git a/config/dot/hypr/env.lua b/config/dot/hypr/env.lua index 07f6937..f76f7e1 100644 --- a/config/dot/hypr/env.lua +++ b/config/dot/hypr/env.lua @@ -9,6 +9,8 @@ -- instead. They still work here; see the session notes in autostart.lua. -- ───────────────────────────────────────────────────────────────────────────── +local prefs = require("prefs") + -- ── GPU selection ─────────────────────────────────────────────────────────── -- 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. @@ -47,7 +49,7 @@ end -- (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 -- 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") -- ── Toolkits ──────────────────────────────────────────────────────────────── diff --git a/config/dot/hypr/looks.lua b/config/dot/hypr/looks.lua index 0caf85d..e5a8556 100644 --- a/config/dot/hypr/looks.lua +++ b/config/dot/hypr/looks.lua @@ -144,6 +144,11 @@ hl.config({ disable_hyprland_logo = not prefs.get("hyprlandLogo", 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 -- followed the preference, the compositor and the shell would disagree -- about the interface font and nobody would notice until a tooltip diff --git a/config/dot/quickshell/config/PreferenceSchema.qml b/config/dot/quickshell/config/PreferenceSchema.qml index 7ee0c0e..c55b267 100644 --- a/config/dot/quickshell/config/PreferenceSchema.qml +++ b/config/dot/quickshell/config/PreferenceSchema.qml @@ -548,6 +548,12 @@ Singleton { detail: "Swap the primary and secondary buttons", 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 ──────────────────────────────────────────────────────── // @@ -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 ────────────────────────────────────────────────────── // The single largest thing in this desktop that used to be changeable // only by editing Theme.qml. @@ -835,6 +859,91 @@ Singleton { label: "Interface text size", 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 ─────────────────────────────────────────────────── // Backed by gsettings so GTK applications agree with the shell, and diff --git a/config/dot/quickshell/modules/settings/AppearancePage.qml b/config/dot/quickshell/modules/settings/AppearancePage.qml index f5a9db4..86c31d6 100644 --- a/config/dot/quickshell/modules/settings/AppearancePage.qml +++ b/config/dot/quickshell/modules/settings/AppearancePage.qml @@ -17,8 +17,10 @@ import qs.services SettingsPage { id: root + property string expandedPicker: "" + 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 { Column { @@ -83,7 +85,7 @@ SettingsPage { } SettingsCard { - title: "Typography" + title: "Shell typography" subtitle: Fonts.lastError !== "" ? Fonts.lastError : "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 { title: "Windows" 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 } - } } diff --git a/config/dot/quickshell/modules/settings/MousePage.qml b/config/dot/quickshell/modules/settings/MousePage.qml index c6a3fcc..8d945da 100644 --- a/config/dot/quickshell/modules/settings/MousePage.qml +++ b/config/dot/quickshell/modules/settings/MousePage.qml @@ -31,7 +31,12 @@ SettingsPage { ChoiceRow { setting: "accelProfile" } ToggleRow { setting: "naturalScroll" } 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 { diff --git a/config/dot/quickshell/scripts/panama-desktop-style b/config/dot/quickshell/scripts/panama-desktop-style new file mode 100755 index 0000000..3edf32b --- /dev/null +++ b/config/dot/quickshell/scripts/panama-desktop-style @@ -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()) diff --git a/config/dot/quickshell/services/Accessibility.qml b/config/dot/quickshell/services/Accessibility.qml index de6d284..1dbea13 100644 --- a/config/dot/quickshell/services/Accessibility.qml +++ b/config/dot/quickshell/services/Accessibility.qml @@ -25,25 +25,14 @@ import qs.config Singleton { id: root - property string cursorTheme: "" 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 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 // Process runs one at a time. property var pending: [] @@ -71,7 +60,6 @@ Singleton { } Component.onCompleted: { - themeQuery.running = true; settle.restart(); } @@ -102,10 +90,7 @@ Singleton { ["gsettings", "set", "org.gnome.desktop.interface", "cursor-size", size], ["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); } } diff --git a/config/dot/quickshell/services/DesktopStyle.qml b/config/dot/quickshell/services/DesktopStyle.qml new file mode 100644 index 0000000..c47b5b0 --- /dev/null +++ b/config/dot/quickshell/services/DesktopStyle.qml @@ -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"); + } +} diff --git a/config/dot/quickshell/services/SettingsSearch.qml b/config/dot/quickshell/services/SettingsSearch.qml index 4bd421d..90cd8b8 100644 --- a/config/dot/quickshell/services/SettingsSearch.qml +++ b/config/dot/quickshell/services/SettingsSearch.qml @@ -17,12 +17,20 @@ import qs.config Singleton { 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 // entry here still appears in results and routes to Home rather than being // dropped, so adding a group can never make a setting unreachable. readonly property var groupPages: ({ "clock": "appearance", "vitals": "appearance", + "typography": "appearance", + "themes": "appearance", + "titlebar": "appearance", "windows": "appearance", "effects": "appearance", "wallpaper": "appearance", diff --git a/tests/quickshell/desktop-style-contract.sh b/tests/quickshell/desktop-style-contract.sh new file mode 100755 index 0000000..b901a87 --- /dev/null +++ b/tests/quickshell/desktop-style-contract.sh @@ -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'